mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-10-09 16:53:59 +02:00
added scripts feature
Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
37 files changed
+4319
-82
No files matched your search
@@ -595,6 +595,7 @@ export class API {
|
||||
denyPageID,
|
||||
evasionPageID,
|
||||
webhooks,
|
||||
scripts,
|
||||
constraintWeekDays,
|
||||
constraintStartTime,
|
||||
constraintEndTime,
|
||||
@@ -623,6 +624,7 @@ export class API {
|
||||
denyPageID,
|
||||
evasionPageID,
|
||||
webhooks,
|
||||
scripts,
|
||||
constraintWeekDays,
|
||||
constraintStartTime,
|
||||
constraintEndTime,
|
||||
@@ -683,6 +685,7 @@ export class API {
|
||||
denyPageID,
|
||||
evasionPageID,
|
||||
webhooks,
|
||||
scripts,
|
||||
constraintWeekDays,
|
||||
constraintStartTime,
|
||||
constraintEndTime,
|
||||
@@ -710,6 +713,7 @@ export class API {
|
||||
denyPageID,
|
||||
evasionPageID,
|
||||
webhooks,
|
||||
scripts,
|
||||
constraintWeekDays,
|
||||
constraintStartTime,
|
||||
constraintEndTime,
|
||||
@@ -3355,6 +3359,96 @@ export class API {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* script is the API for script related operations.
|
||||
* Scripts attach to a campaign like webhooks but run a script.
|
||||
*/
|
||||
script = {
|
||||
/**
|
||||
* Create a new script.
|
||||
*
|
||||
* @param {Object} script
|
||||
* @param {string} script.name
|
||||
* @param {string} script.script
|
||||
* @param {string} [script.companyID]
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
create: async ({ name, script, companyID }) => {
|
||||
return await postJSON(this.getPath('/script'), {
|
||||
name: name,
|
||||
script: script,
|
||||
// send null (global scope) rather than an empty string, which the
|
||||
// backend would try to parse as a UUID
|
||||
companyID: companyID || null
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GetAll scripts.
|
||||
*
|
||||
* @param {TableURLParams} options
|
||||
* @param {string|null} companyID
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
getAll: async (options, companyID = null) => {
|
||||
return await getJSON(
|
||||
this.getPath(`/script?${appendQuery(options)}${this.appendCompanyQuery(companyID)}`)
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a script by its ID.
|
||||
*
|
||||
* @param {string} id
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
getByID: async (id) => {
|
||||
return await getJSON(this.getPath(`/script/${id}`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a script.
|
||||
*
|
||||
* @param {Object} script
|
||||
* @param {string} script.id
|
||||
* @param {string} script.name
|
||||
* @param {string} script.script
|
||||
* @param {string} [script.companyID]
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
update: async ({ id, name, script, companyID }) => {
|
||||
return await patchJSON(this.getPath(`/script/${id}`), {
|
||||
name: name,
|
||||
script: script,
|
||||
companyID: companyID || null
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a script by its ID.
|
||||
*
|
||||
* @param {string} id
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
delete: async (id) => {
|
||||
return await deleteJSON(this.getPath(`/script/${id}`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Test-run a script against a simulated campaign event. Captures what the
|
||||
* script does (logs, info/emitEvent, fetches, errors) without touching a
|
||||
* campaign.
|
||||
*
|
||||
* @param {Object} args
|
||||
* @param {string} args.script
|
||||
* @param {Object} args.event
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
test: async ({ script, event }) => {
|
||||
return await postJSON(this.getPath('/script/test'), { script, event });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* identifier is for campaign identifiers, ala. 'rid' in gophish
|
||||
*/
|
||||
|
||||
@@ -310,6 +310,7 @@
|
||||
class="flex items-center relative"
|
||||
class:w-28={size == 'small'}
|
||||
class:w-60={size == 'normal'}
|
||||
class:w-full={size == 'full'}
|
||||
>
|
||||
<input
|
||||
bind:this={inputElement}
|
||||
|
||||
@@ -166,6 +166,11 @@
|
||||
remote_browser: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25m18 0A2.25 2.25 0 0 0 18.75 3H5.25A2.25 2.25 0 0 0 3 5.25m18 0H3" />
|
||||
</svg>
|
||||
`,
|
||||
|
||||
script: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z" />
|
||||
</svg>
|
||||
`
|
||||
};
|
||||
|
||||
@@ -176,6 +181,7 @@
|
||||
'/campaign-template/': 'campaign_templates',
|
||||
'/filter/': 'filters',
|
||||
'/webhook/': 'webhooks',
|
||||
'/script/': 'script',
|
||||
'/recipient/': 'recipients_overview',
|
||||
'/recipient/group/': 'recipient_groups',
|
||||
'/domain/': 'domains_overview',
|
||||
|
||||
@@ -107,6 +107,11 @@
|
||||
remote_browser: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25m18 0A2.25 2.25 0 0 0 18.75 3H5.25A2.25 2.25 0 0 0 3 5.25m18 0H3" />
|
||||
</svg>
|
||||
`,
|
||||
|
||||
script: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z" />
|
||||
</svg>
|
||||
`,
|
||||
|
||||
tools: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
||||
@@ -129,6 +134,7 @@
|
||||
'/campaign-template/': 'campaign_templates',
|
||||
'/filter/': 'filters',
|
||||
'/webhook/': 'webhooks',
|
||||
'/script/': 'script',
|
||||
'/recipient/': 'recipients_overview',
|
||||
'/recipient/group/': 'recipient_groups',
|
||||
'/domain/': 'domains_overview',
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import * as monaco from 'monaco-editor';
|
||||
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
||||
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
|
||||
import { vimModeEnabled } from '$lib/store/vimMode.js';
|
||||
import {
|
||||
setupVimClipboardIntegration,
|
||||
destroyVimClipboardIntegration
|
||||
} from '$lib/utils/vimClipboard.js';
|
||||
import * as vimModule from 'monaco-vim';
|
||||
import TextField from '$lib/components/TextField.svelte';
|
||||
import TextFieldSelect from '$lib/components/TextFieldSelect.svelte';
|
||||
import SimpleCodeEditor from '$lib/components/editor/SimpleCodeEditor.svelte';
|
||||
import { api } from '$lib/api/apiProxy.js';
|
||||
import { eventDisplayLabel } from '$lib/consts/events.js';
|
||||
|
||||
|
||||
// --- test runner state ---------------------------------------------------
|
||||
const triggerEvents = [
|
||||
'campaign_recipient_message_sent',
|
||||
'campaign_recipient_message_read',
|
||||
'campaign_recipient_before_page_visited',
|
||||
'campaign_recipient_page_visited',
|
||||
'campaign_recipient_after_page_visited',
|
||||
'campaign_recipient_submitted_data',
|
||||
'campaign_recipient_reported',
|
||||
'campaign_recipient_evasion_page_visited',
|
||||
'campaign_recipient_deny_page_visited',
|
||||
'campaign_recipient_training_started',
|
||||
'campaign_recipient_training_completed'
|
||||
];
|
||||
const triggerEventOptions = triggerEvents.map((e) => ({ value: e, label: eventDisplayLabel(e) }));
|
||||
// only submitted_data carries a data payload (the parsed form body, same as the
|
||||
// webhook data); every other event has no data attached
|
||||
const dataBearingEvents = new Set(['campaign_recipient_submitted_data']);
|
||||
let testEventName = 'campaign_recipient_submitted_data';
|
||||
let testCampaignName = 'Test Campaign';
|
||||
let testEmail = 'target@example.test';
|
||||
let testDataText = '{\n "username": "victim",\n "password": "hunter2"\n}';
|
||||
let testResult = null;
|
||||
let testError = '';
|
||||
let testing = false;
|
||||
// 'script' shows the editor full-size, 'test' shows the test runner full-size
|
||||
let view = 'script';
|
||||
$: eventHasData = dataBearingEvents.has(testEventName);
|
||||
|
||||
async function runTest() {
|
||||
testError = '';
|
||||
testResult = null;
|
||||
let data = {};
|
||||
if (eventHasData && testDataText.trim()) {
|
||||
try {
|
||||
data = JSON.parse(testDataText);
|
||||
} catch (e) {
|
||||
testError = 'Event data is not valid JSON: ' + e.message;
|
||||
return;
|
||||
}
|
||||
}
|
||||
testing = true;
|
||||
try {
|
||||
const res = await api.script.test({
|
||||
script,
|
||||
event: { name: testEventName, campaignName: testCampaignName, email: testEmail, data }
|
||||
});
|
||||
if (!res.success) {
|
||||
testError = res.error || 'Test failed';
|
||||
return;
|
||||
}
|
||||
testResult = res.data;
|
||||
} catch (e) {
|
||||
testError = e?.message || String(e);
|
||||
} finally {
|
||||
testing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Props
|
||||
/** @type {string} */
|
||||
export let name = '';
|
||||
/** @type {string} script is the JS source to edit */
|
||||
export let script = '';
|
||||
|
||||
let editor;
|
||||
let editorContainer;
|
||||
let completionProvider;
|
||||
let isDark = false;
|
||||
let vimStatusBarEl = null;
|
||||
let vimModeInstance = null;
|
||||
let localVimMode = false;
|
||||
let isDestroyed = false;
|
||||
|
||||
// scriptDTS is the single source of truth the editor consumes for
|
||||
// autocomplete and inline type checking. It must match the bindings the
|
||||
// backend script engine exposes (backend/script/script.go).
|
||||
const scriptDTS = `
|
||||
/** The event that triggered this script. Already filtered for the
|
||||
* campaign's anonymity and the configured data level, so fields may be empty. */
|
||||
declare const event: {
|
||||
/** event name, e.g. "campaign_recipient_submitted_data" */
|
||||
name: string;
|
||||
/** the campaign id */
|
||||
campaignId: string;
|
||||
/** the recipient id, empty for campaign level events */
|
||||
recipientId: string;
|
||||
/** the campaign name, empty at the "none" data level */
|
||||
campaignName: string;
|
||||
/** the recipient email, only at "full" level on a non anonymous campaign */
|
||||
email: string;
|
||||
/** captured data, only at "full" level */
|
||||
data: Record<string, any>;
|
||||
};
|
||||
|
||||
interface FetchOptions {
|
||||
/** HTTP method: GET (default), POST, PUT, PATCH, DELETE, ... */
|
||||
method?: string;
|
||||
/** request headers, e.g. { 'Content-Type': 'application/json' } */
|
||||
headers?: Record<string, string>;
|
||||
/** request body (a string; use encode.json(obj) for JSON) */
|
||||
body?: string;
|
||||
/** route this request through a proxy: http://, https:// or socks5:// */
|
||||
proxy?: string;
|
||||
/** per request timeout in milliseconds (max 30000) */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface FetchResponse {
|
||||
/** HTTP status code, e.g. 200 */
|
||||
status: number;
|
||||
/** response headers */
|
||||
headers: Record<string, string>;
|
||||
/** response body as a string (use decode.json(res.body) to parse JSON) */
|
||||
body: string;
|
||||
}
|
||||
|
||||
declare const http: {
|
||||
/**
|
||||
* Send a synchronous HTTP request and return the response. Any method is
|
||||
* supported. Blocks until the response arrives or the timeout fires; throws
|
||||
* on a network error, so wrap it in try/catch if you want to handle failures.
|
||||
*
|
||||
* @example
|
||||
* // GET and parse JSON
|
||||
* const res = http.fetch('https://api.example.test/users/42');
|
||||
* if (res.status === 200) {
|
||||
* const user = decode.json(res.body);
|
||||
* log('got user', { name: user.name });
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // capture from a response body with a regex — the whole JS RegExp API
|
||||
* // works (match/matchAll/exec/replace/test); use numbered groups (m[1])
|
||||
* const res = http.fetch('https://api.example.test/login');
|
||||
* const m = res.body.match(/"csrf_token":"([A-Za-z0-9._-]+)"/);
|
||||
* const token = m && m[1];
|
||||
*
|
||||
* @example
|
||||
* // POST JSON with headers
|
||||
* const res = http.fetch('https://api.example.test/hook', {
|
||||
* method: 'POST',
|
||||
* headers: { 'Content-Type': 'application/json' },
|
||||
* body: encode.json({ event: event.name, email: event.email })
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* // PUT with a signed header and a timeout
|
||||
* http.fetch('https://api.example.test/items/1', {
|
||||
* method: 'PUT',
|
||||
* headers: { 'X-Signature': hmac.sha256(secret, body) },
|
||||
* body: body,
|
||||
* timeoutMs: 5000
|
||||
* });
|
||||
*/
|
||||
fetch(url: string, options?: FetchOptions): FetchResponse;
|
||||
};
|
||||
|
||||
/** Output encoding for hash / hmac / random. Defaults to "hex". */
|
||||
type OutputEncoding = 'hex' | 'base64' | 'base64url' | 'base32';
|
||||
|
||||
declare const encode: {
|
||||
base64(s: string): string;
|
||||
/** url-safe base64, no padding (JWT / OAuth) */
|
||||
base64url(s: string): string;
|
||||
base32(s: string): string;
|
||||
hex(s: string): string;
|
||||
/** query escape (space as +) */
|
||||
url(s: string): string;
|
||||
/** path segment escape */
|
||||
urlPath(s: string): string;
|
||||
html(s: string): string;
|
||||
/** JSON stringify any value */
|
||||
json(value: any): string;
|
||||
/** object -> application/x-www-form-urlencoded */
|
||||
form(obj: Record<string, any>): string;
|
||||
/** gzip then base64 */
|
||||
gzip(s: string): string;
|
||||
/** raw deflate then base64 */
|
||||
deflate(s: string): string;
|
||||
};
|
||||
|
||||
declare const decode: {
|
||||
base64(s: string): string;
|
||||
base64url(s: string): string;
|
||||
base32(s: string): string;
|
||||
hex(s: string): string;
|
||||
url(s: string): string;
|
||||
urlPath(s: string): string;
|
||||
html(s: string): string;
|
||||
/** JSON parse a string into a value */
|
||||
json(s: string): any;
|
||||
/** form-encoded string -> object (repeated key -> array) */
|
||||
form(s: string): Record<string, any>;
|
||||
/** base64 of gzip -> string */
|
||||
gzip(s: string): string;
|
||||
/** base64 of deflate -> string */
|
||||
deflate(s: string): string;
|
||||
};
|
||||
|
||||
/** Hashing. hash.sha256(input) -> hex; pass an encoding for base64 etc. */
|
||||
declare const hash: {
|
||||
md5(input: string, encoding?: OutputEncoding): string;
|
||||
sha1(input: string, encoding?: OutputEncoding): string;
|
||||
sha256(input: string, encoding?: OutputEncoding): string;
|
||||
sha384(input: string, encoding?: OutputEncoding): string;
|
||||
sha512(input: string, encoding?: OutputEncoding): string;
|
||||
};
|
||||
|
||||
/** Keyed HMAC signing, e.g. for request or webhook signatures. */
|
||||
declare const hmac: {
|
||||
sha1(key: string, message: string, encoding?: OutputEncoding): string;
|
||||
sha256(key: string, message: string, encoding?: OutputEncoding): string;
|
||||
sha384(key: string, message: string, encoding?: OutputEncoding): string;
|
||||
sha512(key: string, message: string, encoding?: OutputEncoding): string;
|
||||
};
|
||||
|
||||
declare const jwt: {
|
||||
/** Split and JSON-parse a JWT. Does NOT verify the signature. */
|
||||
decode(token: string): { header: any; payload: any; signature: string };
|
||||
};
|
||||
|
||||
/** Secure random for nonces, PKCE verifiers, OAuth state. */
|
||||
declare const random: {
|
||||
/** n random bytes (1..4096) in the given encoding, default hex */
|
||||
bytes(n: number, encoding?: OutputEncoding): string;
|
||||
uuid(): string;
|
||||
};
|
||||
|
||||
/** Write a line to the SERVER logs (for debugging). Not visible in the app. */
|
||||
declare function log(message: string, data?: any): void;
|
||||
|
||||
/** Record a campaign_recipient_info event, visible in the campaign timeline.
|
||||
* The detail follows the campaign's data-retention and anonymity rules.
|
||||
* Uncaught exceptions and timeouts are recorded automatically the same way. */
|
||||
declare function info(message: string, data?: Record<string, any>): void;
|
||||
|
||||
/** The events a script may create with emitEvent. Server-detected outcomes
|
||||
* (opens, clicks, reports, delivery, training) cannot be forged. */
|
||||
type EmittableEvent = 'campaign_recipient_submitted_data' | 'campaign_recipient_info';
|
||||
|
||||
/** Create a new campaign event in this same campaign and recipient context, for
|
||||
* data the script itself authored (e.g. a token obtained via http.fetch). Goes
|
||||
* through the same storage and anonymization rules as native capture: data is
|
||||
* stored only when the campaign keeps submitted data, and stripped on anonymous
|
||||
* campaigns.
|
||||
*
|
||||
* @example
|
||||
* // record a token the script redeemed as submitted data
|
||||
* emitEvent('campaign_recipient_submitted_data', { accessToken: token }); */
|
||||
declare function emitEvent(name: EmittableEvent, data?: Record<string, any>): void;
|
||||
|
||||
/** End the script early. */
|
||||
declare function stop(): void;
|
||||
`;
|
||||
|
||||
const defaultScript = `// Runs when a subscribed campaign event fires.
|
||||
// Available: event, http.fetch, encode, decode, hash, hmac, jwt, random, log, info, emitEvent, stop
|
||||
// (encode/decode/hash/hmac/jwt/random are the data transform toolkit)
|
||||
log('event received', { name: event.name });
|
||||
|
||||
// event.data holds the submitted form fields on a submitted_data event.
|
||||
// Read whatever fields your landing page posts:
|
||||
var username = event.data && event.data.username;
|
||||
if (username) {
|
||||
info('captured a submission', { username: username });
|
||||
}
|
||||
|
||||
// Example: forward the submitted data to an external service (set a real URL)
|
||||
// const res = http.fetch('https://your-endpoint.test/hook', {
|
||||
// method: 'POST',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: encode.json({ event: event.name, email: event.email, data: event.data })
|
||||
// });
|
||||
// const parsed = decode.json(res.body);
|
||||
// log('forwarded', { status: res.status, id: parsed.id });
|
||||
`;
|
||||
|
||||
function destroyVimMode() {
|
||||
if (vimModeInstance) {
|
||||
destroyVimClipboardIntegration();
|
||||
vimModeInstance.dispose();
|
||||
vimModeInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!script) {
|
||||
script = defaultScript;
|
||||
}
|
||||
isDark = document.documentElement.classList.contains('dark');
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const newIsDark = document.documentElement.classList.contains('dark');
|
||||
if (newIsDark !== isDark) {
|
||||
isDark = newIsDark;
|
||||
if (editor) monaco.editor.setTheme(isDark ? 'vs-dark' : 'vs-light');
|
||||
}
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
/* @ts-ignore */
|
||||
self.MonacoEnvironment = {
|
||||
getWorker: function (_, label) {
|
||||
if (label === 'typescript' || label === 'javascript') {
|
||||
return new tsWorker();
|
||||
}
|
||||
return new editorWorker();
|
||||
}
|
||||
};
|
||||
|
||||
// noLib removes the browser DOM lib so dot completion only shows the
|
||||
// declared script API. 1108 is 'return' outside function, valid here
|
||||
// because the script runs inside an implicit IIFE on the backend.
|
||||
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
|
||||
noSemanticValidation: false,
|
||||
noSyntaxValidation: false,
|
||||
diagnosticCodesToIgnore: [1108]
|
||||
});
|
||||
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
|
||||
noLib: true,
|
||||
allowJs: true,
|
||||
checkJs: true,
|
||||
allowNonTsExtensions: true,
|
||||
target: monaco.languages.typescript.ScriptTarget.ES2020,
|
||||
strict: false
|
||||
});
|
||||
completionProvider = monaco.languages.typescript.javascriptDefaults.addExtraLib(
|
||||
scriptDTS,
|
||||
'ts:script.d.ts'
|
||||
);
|
||||
|
||||
editor = monaco.editor.create(editorContainer, {
|
||||
value: script,
|
||||
language: 'javascript',
|
||||
theme: isDark ? 'vs-dark' : 'vs-light',
|
||||
minimap: { enabled: false },
|
||||
wordWrap: 'off',
|
||||
folding: false,
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: 13,
|
||||
automaticLayout: true
|
||||
});
|
||||
|
||||
editor.onDidChangeModelContent(() => {
|
||||
script = editor.getValue();
|
||||
});
|
||||
|
||||
const unsubVim = vimModeEnabled.subscribe((enabled) => {
|
||||
if (isDestroyed) return;
|
||||
localVimMode = enabled;
|
||||
if (enabled) {
|
||||
vimModeInstance = vimModule.initVimMode(editor, vimStatusBarEl);
|
||||
setupVimClipboardIntegration(editor, vimModeInstance, localVimMode, monaco);
|
||||
} else {
|
||||
destroyVimMode();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isDestroyed = true;
|
||||
observer.disconnect();
|
||||
unsubVim();
|
||||
destroyVimMode();
|
||||
if (completionProvider) completionProvider.dispose();
|
||||
if (editor) editor.dispose();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col gap-3">
|
||||
<!-- top bar: name + view toggle -->
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<TextField bind:value={name} placeholder="my-script">Name</TextField>
|
||||
</div>
|
||||
<div
|
||||
class="mb-2 inline-flex overflow-hidden rounded-md border border-gray-300 text-sm dark:border-gray-600"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (view = 'script')}
|
||||
class="px-3 py-1.5 font-medium transition-colors {view === 'script'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'}"
|
||||
>
|
||||
Script
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (view = 'test')}
|
||||
class="px-3 py-1.5 font-medium transition-colors {view === 'test'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'}"
|
||||
>
|
||||
Test run
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- body: editor stays mounted; the test runner overlays it full-size -->
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<!-- editor (kept sized with invisible, not display:none, so Monaco stays laid out) -->
|
||||
<div
|
||||
class="flex h-full flex-col overflow-hidden rounded border border-gray-300 dark:border-gray-700"
|
||||
class:invisible={view !== 'script'}
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between px-3 py-1 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<span class="text-xs font-mono text-gray-500 dark:text-gray-400">JavaScript</span>
|
||||
<button
|
||||
type="button"
|
||||
title="Toggle vim mode"
|
||||
on:click={() => vimModeEnabled.update((v) => !v)}
|
||||
class="h-8 border-2 rounded-md w-20 px-3 text-center cursor-pointer hover:opacity-80 flex items-center justify-center gap-2 transition-colors duration-200"
|
||||
class:font-bold={localVimMode}
|
||||
class:bg-blue-600={localVimMode}
|
||||
class:dark:bg-blue-500={localVimMode}
|
||||
class:text-white={localVimMode}
|
||||
class:border-blue-600={localVimMode}
|
||||
class:dark:border-blue-500={localVimMode}
|
||||
class:text-gray-700={!localVimMode}
|
||||
class:dark:text-gray-200={!localVimMode}
|
||||
class:bg-white={!localVimMode}
|
||||
class:dark:bg-gray-700={!localVimMode}
|
||||
class:border-gray-300={!localVimMode}
|
||||
class:dark:border-gray-600={!localVimMode}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z" />
|
||||
</svg>
|
||||
<span class="text-xs">Vim</span>
|
||||
</button>
|
||||
</div>
|
||||
<div bind:this={editorContainer} class="flex-1" style="min-height: 0;"></div>
|
||||
<div
|
||||
bind:this={vimStatusBarEl}
|
||||
class="h-5 bg-gray-100 dark:bg-gray-800 text-xs text-gray-500 dark:text-gray-400 px-2"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{#if view === 'test'}
|
||||
<!-- fills the body; below lg it stacks and scrolls -->
|
||||
<div
|
||||
class="absolute inset-0 flex min-h-0 flex-col gap-4 overflow-y-auto lg:flex-row lg:overflow-hidden"
|
||||
>
|
||||
<!-- LEFT: input controls (scroll) with the Run button pinned below -->
|
||||
<div class="flex min-h-0 flex-none flex-col lg:w-80 xl:w-96">
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto pr-1">
|
||||
<TextFieldSelect
|
||||
id="script-test-event"
|
||||
bind:value={testEventName}
|
||||
options={triggerEventOptions}
|
||||
size="normal"
|
||||
>
|
||||
Event
|
||||
</TextFieldSelect>
|
||||
<TextField bind:value={testCampaignName}>Campaign name</TextField>
|
||||
<TextField bind:value={testEmail}>Recipient email</TextField>
|
||||
|
||||
<!-- only campaign_recipient_submitted_data carries data (the parsed form body);
|
||||
other events have no data attached -->
|
||||
{#if eventHasData}
|
||||
<div class="flex w-full flex-col py-2">
|
||||
<p class="py-1 font-semibold text-slate-600 dark:text-gray-400">Event data (JSON)</p>
|
||||
<SimpleCodeEditor
|
||||
bind:value={testDataText}
|
||||
language="json"
|
||||
height="medium"
|
||||
showVimToggle={false}
|
||||
showExpandButton={false}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if testError}
|
||||
<div
|
||||
class="rounded border border-red-300 bg-red-50 p-2 text-xs text-red-700 dark:border-red-700 dark:bg-red-900/30 dark:text-red-300"
|
||||
>
|
||||
{testError}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
on:click={runTest}
|
||||
disabled={testing}
|
||||
class="mt-3 flex w-full flex-none items-center justify-center gap-2 rounded-md bg-gradient-to-b from-blue-500 to-indigo-400 px-4 py-2 text-sm font-semibold text-white transition-all duration-200 hover:from-blue-400 hover:to-indigo-400 disabled:cursor-not-allowed disabled:opacity-60 dark:from-blue-600 dark:to-indigo-500"
|
||||
>
|
||||
{#if testing}
|
||||
<span class="inline-block h-2 w-2 animate-pulse rounded-full bg-white"></span>
|
||||
Running…
|
||||
{:else}
|
||||
Run test
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: run log -->
|
||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
|
||||
>Run log</span
|
||||
>
|
||||
{#if testing}
|
||||
<span class="inline-block h-2 w-2 animate-pulse rounded-full bg-green-400"></span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if testResult || testError}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => {
|
||||
testResult = null;
|
||||
testError = '';
|
||||
}}
|
||||
class="text-xs font-medium text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- log body (same rendering as the remote browser editor) -->
|
||||
<div
|
||||
class="min-h-[16rem] flex-1 select-text space-y-0.5 overflow-y-auto rounded bg-gray-900 p-2 font-mono text-xs text-gray-200 lg:min-h-0 dark:bg-gray-950"
|
||||
>
|
||||
{#if !testResult}
|
||||
<span class="text-gray-500">No events yet. Click Run test to execute the script.</span>
|
||||
{:else}
|
||||
{#each testResult.entries ?? [] as entry}
|
||||
<div
|
||||
class="leading-5 {entry.type === 'event'
|
||||
? 'text-blue-300'
|
||||
: entry.type === 'info'
|
||||
? 'text-sky-300'
|
||||
: entry.type === 'error'
|
||||
? 'text-red-400'
|
||||
: entry.type === 'done'
|
||||
? 'text-green-400'
|
||||
: 'text-gray-400'}"
|
||||
>
|
||||
{#if entry.type === 'event'}
|
||||
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
|
||||
<span class="text-blue-400"> emit </span>
|
||||
<span class="text-yellow-400">{entry.key}</span>
|
||||
<span class="text-gray-300"> = </span>
|
||||
<span>{JSON.stringify(entry.value)}</span>
|
||||
{:else if entry.type === 'info'}
|
||||
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
|
||||
<span class="text-sky-400"> ℹ info</span>
|
||||
<span class="ml-1 text-sky-200">{entry.message}</span>
|
||||
{#if entry.data !== undefined && entry.data !== null}
|
||||
<span class="text-cyan-300"> {JSON.stringify(entry.data)}</span>
|
||||
{/if}
|
||||
{:else if entry.type === 'done'}
|
||||
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
|
||||
<span class="text-green-400"> ✓ done</span>
|
||||
{:else}
|
||||
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
|
||||
<span> {entry.message}</span>
|
||||
{#if entry.data !== undefined && entry.data !== null}
|
||||
<span class="text-cyan-300"> {JSON.stringify(entry.data)}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
// Human-readable display names for campaign events, shared so the campaign
|
||||
// wizard and the script editor label events the same way instead of showing
|
||||
// the raw internal names.
|
||||
export const eventDisplayNames = {
|
||||
campaign_closed: 'Campaign Closed',
|
||||
campaign_recipient_message_sent: 'Message Sent',
|
||||
campaign_recipient_message_failed: 'Message Failed',
|
||||
campaign_recipient_message_read: 'Message Read',
|
||||
campaign_recipient_submitted_data: 'Submitted Data',
|
||||
campaign_recipient_reported: 'Reported',
|
||||
campaign_recipient_evasion_page_visited: 'Evasion Page Visited',
|
||||
campaign_recipient_before_page_visited: 'Before Page Visited',
|
||||
campaign_recipient_page_visited: 'Page Visited',
|
||||
campaign_recipient_after_page_visited: 'After Page Visited',
|
||||
campaign_recipient_deny_page_visited: 'Deny Page Visited',
|
||||
campaign_recipient_training_started: 'Training Started',
|
||||
campaign_recipient_training_completed: 'Training Completed'
|
||||
};
|
||||
|
||||
// eventDisplayLabel returns the friendly label for an event name, falling back
|
||||
// to the raw name for anything unmapped.
|
||||
export const eventDisplayLabel = (name) => eventDisplayNames[name] || name;
|
||||
@@ -101,6 +101,11 @@ export const route = {
|
||||
label: 'Webhooks',
|
||||
route: '/webhook/'
|
||||
},
|
||||
script: {
|
||||
label: 'Scripts',
|
||||
route: '/script/',
|
||||
blackbox: true
|
||||
},
|
||||
userGuide: {
|
||||
label: 'User Guide',
|
||||
route: 'https://phishing.club/guide/introduction/',
|
||||
@@ -118,7 +123,13 @@ export const menu = [
|
||||
{
|
||||
label: 'Campaigns',
|
||||
type: 'submenu',
|
||||
items: [route.campaigns, route.campaignTemplates, route.allowDeny, route.webhook]
|
||||
items: [
|
||||
route.campaigns,
|
||||
route.campaignTemplates,
|
||||
route.allowDeny,
|
||||
route.webhook,
|
||||
route.script
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { newTableURLParams } from '$lib/service/tableURLParams.js';
|
||||
import { eventDisplayNames } from '$lib/consts/events.js';
|
||||
import Headline from '$lib/components/Headline.svelte';
|
||||
import TextField from '$lib/components/TextField.svelte';
|
||||
import TableRow from '$lib/components/table/TableRow.svelte';
|
||||
@@ -178,21 +179,8 @@
|
||||
'campaign_closed'
|
||||
];
|
||||
|
||||
// human-readable display names for webhook events
|
||||
const webhookEventDisplayNames = {
|
||||
campaign_closed: 'Campaign Closed',
|
||||
campaign_recipient_message_sent: 'Message Sent',
|
||||
campaign_recipient_message_failed: 'Message Failed',
|
||||
campaign_recipient_message_read: 'Message Read',
|
||||
campaign_recipient_submitted_data: 'Submitted Data',
|
||||
campaign_recipient_evasion_page_visited: 'Evasion Page Visited',
|
||||
campaign_recipient_before_page_visited: 'Before Page Visited',
|
||||
campaign_recipient_page_visited: 'Page Visited',
|
||||
campaign_recipient_after_page_visited: 'After Page Visited',
|
||||
campaign_recipient_deny_page_visited: 'Deny Page Visited',
|
||||
campaign_recipient_training_started: 'Training Started',
|
||||
campaign_recipient_training_completed: 'Training Completed'
|
||||
};
|
||||
// human-readable display names for webhook events (shared with the script editor)
|
||||
const webhookEventDisplayNames = eventDisplayNames;
|
||||
|
||||
// create display options array with nice names
|
||||
const webhookEventDisplayOptions = webhookEventOptions.map((event) => ({
|
||||
@@ -279,6 +267,11 @@
|
||||
let denyPageMap = new BiMap({});
|
||||
let allowDenyMap = new BiMap({});
|
||||
let webhookMap = new BiMap({});
|
||||
// scripts reuse the webhook event bit map and data levels. The feature is
|
||||
// only shown when the server has it enabled (the list endpoint returns 404
|
||||
// otherwise).
|
||||
let scriptMap = new BiMap({});
|
||||
let scriptsEnabled = false;
|
||||
let modalMode = null;
|
||||
let scheduleType = 'basic';
|
||||
let allowDenyType = 'none';
|
||||
@@ -493,6 +486,7 @@
|
||||
obfuscate: false,
|
||||
selectedCount: 0,
|
||||
webhooks: [], // array of {id, includeData, events}
|
||||
scripts: [], // array of {id, includeData, events}
|
||||
jitterMin: 0,
|
||||
jitterMax: 0
|
||||
};
|
||||
@@ -768,6 +762,21 @@
|
||||
return api.webhook.getAll(options, contextCompanyID);
|
||||
});
|
||||
webhookMap = BiMap.FromArrayOfObjects(webhooks);
|
||||
|
||||
// load scripts only when the feature is enabled; the endpoint returns
|
||||
// 404 when disabled, in which case the scripts UI stays hidden.
|
||||
try {
|
||||
const res = await api.script.getAll({}, contextCompanyID);
|
||||
if (res.success) {
|
||||
scriptsEnabled = true;
|
||||
const scripts = await fetchAllRows((options) => {
|
||||
return api.script.getAll(options, contextCompanyID);
|
||||
});
|
||||
scriptMap = BiMap.FromArrayOfObjects(scripts);
|
||||
}
|
||||
} catch (e) {
|
||||
scriptsEnabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse a YYYY-MM-DD string as local midnight, not UTC midnight.
|
||||
@@ -924,12 +933,19 @@
|
||||
constraintStartTime: contraintStartTimeUTC,
|
||||
constraintEndTime: contraintEndTimeUTC,
|
||||
webhooks: formValues.webhooks
|
||||
.filter((wh) => wh.id !== null)
|
||||
.filter((wh) => wh.id)
|
||||
.map((wh) => ({
|
||||
webhookID: wh.id,
|
||||
webhookIncludeData: wh.includeData,
|
||||
webhookEvents: webhookEventsToBinary(wh.events)
|
||||
})),
|
||||
scripts: formValues.scripts
|
||||
.filter((a) => a.id)
|
||||
.map((a) => ({
|
||||
scriptID: a.id,
|
||||
scriptIncludeData: a.includeData,
|
||||
scriptEvents: webhookEventsToBinary(a.events)
|
||||
})),
|
||||
jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null,
|
||||
jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null,
|
||||
scheduleAt: scheduleAtUTC
|
||||
@@ -1003,12 +1019,19 @@
|
||||
denyPageID: denyPageMap.byValueOrNull(formValues.denyPageValue),
|
||||
evasionPageID: denyPageMap.byValueOrNull(formValues.evasionPageValue),
|
||||
webhooks: formValues.webhooks
|
||||
.filter((wh) => wh.id !== null)
|
||||
.filter((wh) => wh.id)
|
||||
.map((wh) => ({
|
||||
webhookID: wh.id,
|
||||
webhookIncludeData: wh.includeData,
|
||||
webhookEvents: webhookEventsToBinary(wh.events)
|
||||
})),
|
||||
scripts: formValues.scripts
|
||||
.filter((a) => a.id)
|
||||
.map((a) => ({
|
||||
scriptID: a.id,
|
||||
scriptIncludeData: a.includeData,
|
||||
scriptEvents: webhookEventsToBinary(a.events)
|
||||
})),
|
||||
jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null,
|
||||
jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null,
|
||||
scheduleAt: scheduleAtUTC
|
||||
@@ -1160,6 +1183,7 @@
|
||||
obfuscate: false,
|
||||
selectedCount: 0,
|
||||
webhooks: [],
|
||||
scripts: [],
|
||||
jitterMin: 0,
|
||||
jitterMax: 0
|
||||
};
|
||||
@@ -1297,6 +1321,16 @@
|
||||
}
|
||||
// no webhooks
|
||||
return [];
|
||||
})(),
|
||||
scripts: (() => {
|
||||
if (campaign.scripts && campaign.scripts.length > 0) {
|
||||
return campaign.scripts.map((a) => ({
|
||||
id: a.scriptID,
|
||||
includeData: a.scriptIncludeData ?? 'full',
|
||||
events: webhookEventsFromBinary(a.scriptEvents ?? 0)
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
})()
|
||||
};
|
||||
|
||||
@@ -1345,6 +1379,7 @@
|
||||
showAdvancedOptionsStep4 = !!(
|
||||
campaign.webhookID ||
|
||||
campaign.webhooks?.length ||
|
||||
campaign.scripts?.length ||
|
||||
campaign.denyPage ||
|
||||
campaign.evasionPage ||
|
||||
campaign.allowDeny?.length ||
|
||||
@@ -1504,6 +1539,37 @@
|
||||
formValues.webhooks = [...formValues.webhooks]; // trigger reactivity
|
||||
};
|
||||
|
||||
// script helper functions, mirroring the webhook ones
|
||||
const addScript = () => {
|
||||
formValues.scripts = [
|
||||
...formValues.scripts,
|
||||
{
|
||||
id: null,
|
||||
includeData: 'full',
|
||||
events: [...webhookEventOptions] // all events by default
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const removeScript = (index) => {
|
||||
formValues.scripts = formValues.scripts.filter((_, i) => i !== index);
|
||||
};
|
||||
|
||||
const toggleScriptEvent = (scriptIndex, eventValue) => {
|
||||
const script = formValues.scripts[scriptIndex];
|
||||
const isSelected = script.events.includes(eventValue);
|
||||
|
||||
if (isSelected) {
|
||||
// prevent unselecting the last item
|
||||
if (script.events.length > 1) {
|
||||
script.events = script.events.filter((e) => e !== eventValue);
|
||||
}
|
||||
} else {
|
||||
script.events = [...script.events, eventValue];
|
||||
}
|
||||
formValues.scripts = [...formValues.scripts]; // trigger reactivity
|
||||
};
|
||||
|
||||
// check if user is in the correct context for campaign actions
|
||||
const isContextMismatch = (campaign) => {
|
||||
const context = appStateService.getContext();
|
||||
@@ -2368,6 +2434,110 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConditionalDisplay show="blackbox">
|
||||
{#if scriptsEnabled}
|
||||
<div class="mb-6 pt-4">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center py-2">
|
||||
<p class="font-semibold text-slate-600 dark:text-gray-400">Scripts</p>
|
||||
<ToolTip>
|
||||
Run a script when a campaign event fires. Each script has its own
|
||||
data level and event filters, the same as webhooks.
|
||||
</ToolTip>
|
||||
<div
|
||||
class="bg-gray-100 dark:bg-gray-800/60 ml-2 px-2 rounded-md transition-colors duration-200 h-6 flex items-center"
|
||||
>
|
||||
<p
|
||||
class="text-slate-600 dark:text-gray-400 text-xs transition-colors duration-200"
|
||||
>
|
||||
optional
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3 max-w-lg">
|
||||
{#each formValues.scripts as script, index}
|
||||
<div
|
||||
class="flex flex-col gap-3 p-4 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800/50 dark:to-gray-800/30 rounded-lg border border-gray-300 dark:border-gray-600/50 shadow-sm hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<div class="flex gap-2 items-start">
|
||||
<div class="flex-1">
|
||||
<TextFieldSelect
|
||||
id="script-{index}"
|
||||
bind:value={script.id}
|
||||
optional
|
||||
options={scriptMap
|
||||
.keys()
|
||||
.map((k) => ({ value: k, label: scriptMap.byKey(k) }))}
|
||||
>
|
||||
Script
|
||||
</TextFieldSelect>
|
||||
</div>
|
||||
<div class="flex items-end pb-4">
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 hover:bg-gray-200 dark:hover:bg-gray-700/80 rounded-md transition-colors duration-200"
|
||||
on:click={() => removeScript(index)}
|
||||
title="Remove this script"
|
||||
aria-label="Remove script"
|
||||
>
|
||||
<img class="w-4 flex-shrink-0" src="/delete2.svg" alt="" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SelectSquare
|
||||
bind:value={script.includeData}
|
||||
options={webhookDataLevelOptions}
|
||||
label="Data Level"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pt-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<p class="text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
Events
|
||||
</p>
|
||||
<span
|
||||
class="px-2 py-0.5 bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 rounded-full text-xs font-medium"
|
||||
>
|
||||
{script.events.length === webhookEventOptions.length
|
||||
? 'All'
|
||||
: script.events.length} / {webhookEventOptions.length}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-row flex-wrap gap-1.5 max-h-28 overflow-y-auto p-2 bg-white/50 dark:bg-gray-900/30 rounded border border-gray-200 dark:border-gray-700/50"
|
||||
>
|
||||
{#each webhookEventDisplayOptions as eventOption}
|
||||
{@const isSelected = script.events.includes(eventOption.value)}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => toggleScriptEvent(index, eventOption.value)}
|
||||
class="px-2.5 py-1 rounded-md text-xs font-medium transition-colors duration-200 {isSelected
|
||||
? 'bg-green-50 dark:bg-green-900/30 text-green-600 dark:text-green-400 border border-green-400 dark:border-green-500 hover:bg-green-100 dark:hover:bg-green-900/40'
|
||||
: 'bg-white dark:bg-gray-900/60 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700/60 hover:border-blue-300 dark:hover:border-highlight-blue/80 hover:bg-blue-50 dark:hover:bg-highlight-blue/20'}"
|
||||
>
|
||||
{eventOption.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 bg-gradient-to-b from-blue-500 to-indigo-400 dark:from-blue-600 dark:to-indigo-500 hover:from-blue-400 hover:to-indigo-400 dark:hover:from-blue-500 dark:hover:to-indigo-400 text-white font-semibold rounded-md transition-all duration-200"
|
||||
on:click={addScript}
|
||||
>
|
||||
+ Add Script
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</ConditionalDisplay>
|
||||
|
||||
<ConditionalDisplay show="blackbox">
|
||||
<div class="mb-6">
|
||||
<SelectSquare
|
||||
@@ -2810,6 +2980,29 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ConditionalDisplay show="blackbox">
|
||||
{#if scriptsEnabled && formValues.scripts.length > 0}
|
||||
<span class="text-grayblue-dark font-medium">Scripts:</span>
|
||||
<div class="text-pc-darkblue dark:text-white space-y-2">
|
||||
{#each formValues.scripts as script, index}
|
||||
<div class="border-l-2 border-blue-400 pl-3 py-1">
|
||||
<div class="font-medium">
|
||||
{scriptMap.byKey(script.id) || 'Not selected'}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Data Level: <span class="capitalize">{script.includeData}</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Events: {script.events.length === webhookEventOptions.length
|
||||
? 'All Events'
|
||||
: script.events.length + ' selected'}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</ConditionalDisplay>
|
||||
|
||||
{#if formValues.denyPageValue}
|
||||
<span class="text-grayblue-dark font-medium">Deny Page:</span>
|
||||
<span class="text-pc-darkblue dark:text-white"
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
allowDenyIDs: [],
|
||||
webhookID: null,
|
||||
webhooks: [],
|
||||
scripts: [],
|
||||
// groups by name, must be mapped to IDs before sending to the server
|
||||
recipientGroups: [],
|
||||
events: [],
|
||||
@@ -94,6 +95,8 @@
|
||||
};
|
||||
// map of webhook id -> webhook name for display
|
||||
let webhookMap = new BiMap({});
|
||||
// map of script id -> script name for display
|
||||
let scriptMap = new BiMap({});
|
||||
let allowedFilter = null;
|
||||
let campaignRecipients = [];
|
||||
let campaignRecipientsHasNextPage = false;
|
||||
@@ -378,6 +381,7 @@
|
||||
campaign.evasionPage = t.evasionPage;
|
||||
campaign.webhookID = t.webhookID;
|
||||
campaign.webhooks = t.webhooks ?? [];
|
||||
campaign.scripts = t.scripts ?? [];
|
||||
campaign.companyID = t.companyID;
|
||||
campaign.company = t.company;
|
||||
|
||||
@@ -391,6 +395,19 @@
|
||||
console.error('failed to load webhooks for display', e);
|
||||
}
|
||||
|
||||
// load script names for display; the endpoint 404s when the feature is
|
||||
// off, which the catch swallows so the view still renders
|
||||
if (campaign.scripts.length) {
|
||||
try {
|
||||
const allScripts = await fetchAllRows((options) => {
|
||||
return api.script.getAll(options, campaign.companyID ?? null);
|
||||
});
|
||||
scriptMap = BiMap.FromArrayOfObjects(allScripts);
|
||||
} catch (e) {
|
||||
console.error('failed to load scripts for display', e);
|
||||
}
|
||||
}
|
||||
|
||||
// if company exists but name is missing, fetch it
|
||||
if (campaign.companyID && !campaign.company?.name) {
|
||||
try {
|
||||
@@ -2217,6 +2234,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if campaign.scripts?.length}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">Scripts:</span>
|
||||
<div class="text-right">
|
||||
{#each campaign.scripts as s, i}
|
||||
<a
|
||||
class="text-cta-blue dark:text-blue-400 hover:underline"
|
||||
href="/script?edit={s.scriptID}"
|
||||
target="_blank">{scriptMap.byKey(s.scriptID) || s.scriptID}</a
|
||||
>{#if i < campaign.scripts.length - 1}, {/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">Data Saving:</span>
|
||||
<span class="text-pc-darkblue dark:text-white">
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '$lib/api/apiProxy.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { newTableURLParams } from '$lib/service/tableURLParams.js';
|
||||
import { globalButtonDisabledAttributes } from '$lib/utils/form.js';
|
||||
import Headline from '$lib/components/Headline.svelte';
|
||||
import TableRow from '$lib/components/table/TableRow.svelte';
|
||||
import TableCell from '$lib/components/table/TableCell.svelte';
|
||||
import TableUpdateButton from '$lib/components/table/TableUpdateButton.svelte';
|
||||
import TableDeleteButton from '$lib/components/table/TableDeleteButton2.svelte';
|
||||
import FormError from '$lib/components/FormError.svelte';
|
||||
import { addToast } from '$lib/store/toast';
|
||||
import { AppStateService } from '$lib/service/appState';
|
||||
import TableCellAction from '$lib/components/table/TableCellAction.svelte';
|
||||
import TableCellEmpty from '$lib/components/table/TableCellEmpty.svelte';
|
||||
import FormGrid from '$lib/components/FormGrid.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import BigButton from '$lib/components/BigButton.svelte';
|
||||
import FormFooter from '$lib/components/FormFooter.svelte';
|
||||
import Table from '$lib/components/table/Table.svelte';
|
||||
import HeadTitle from '$lib/components/HeadTitle.svelte';
|
||||
import { getModalText } from '$lib/utils/common';
|
||||
import { showIsLoading, hideIsLoading } from '$lib/store/loading.js';
|
||||
import TableCopyButton from '$lib/components/table/TableCopyButton.svelte';
|
||||
import TableDropDownEllipsis from '$lib/components/table/TableDropDownEllipsis.svelte';
|
||||
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
|
||||
import TableCellScope from '$lib/components/table/TableCellScope.svelte';
|
||||
import ScriptEditor from '$lib/components/script/ScriptEditor.svelte';
|
||||
|
||||
// services
|
||||
const appStateService = AppStateService.instance;
|
||||
|
||||
// data
|
||||
let contextCompanyID = null;
|
||||
let formValues = {
|
||||
id: '',
|
||||
name: '',
|
||||
companyID: '',
|
||||
script: ''
|
||||
};
|
||||
let scripts = [];
|
||||
let scriptsHasNextPage = true;
|
||||
// the backend returns 404 on every script endpoint when the feature is off
|
||||
let featureDisabled = false;
|
||||
|
||||
let modalError = '';
|
||||
const tableURLParams = newTableURLParams();
|
||||
let isModalVisible = false;
|
||||
let isSubmitting = false;
|
||||
let isTableLoading = false;
|
||||
let modalMode = null;
|
||||
let modalText = '';
|
||||
// bumped each time the modal opens so the editor remounts with fresh content
|
||||
let editorKey = 0;
|
||||
|
||||
let isDeleteAlertVisible = false;
|
||||
let deleteValues = {
|
||||
id: null,
|
||||
name: null
|
||||
};
|
||||
|
||||
$: {
|
||||
modalText = getModalText('script', modalMode);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (appStateService.getContext()) {
|
||||
contextCompanyID = appStateService.getContext().companyID;
|
||||
formValues.companyID = contextCompanyID;
|
||||
}
|
||||
refreshScripts();
|
||||
tableURLParams.onChange(refreshScripts);
|
||||
|
||||
(async () => {
|
||||
const editID = $page.url.searchParams.get('edit');
|
||||
if (editID) {
|
||||
await openEditModal(editID);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
tableURLParams.unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
const refreshScripts = async () => {
|
||||
try {
|
||||
isTableLoading = true;
|
||||
const result = await getScripts();
|
||||
scripts = result.rows;
|
||||
scriptsHasNextPage = result.hasNextPage;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
isTableLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const getScripts = async () => {
|
||||
const res = await api.script.getAll(tableURLParams, contextCompanyID);
|
||||
if (res.success) {
|
||||
featureDisabled = false;
|
||||
return res.data;
|
||||
}
|
||||
// feature turned off at the server level: show the how-to-enable banner
|
||||
// instead of an error toast
|
||||
if (res.statusCode === 404) {
|
||||
featureDisabled = true;
|
||||
} else {
|
||||
addToast('Failed to get scripts', 'Error');
|
||||
console.error('failed to get scripts', res.error);
|
||||
}
|
||||
return { rows: [], hasNextPage: false };
|
||||
};
|
||||
|
||||
const onSubmit = async (event) => {
|
||||
// FormGrid dispatches saveOnly:true on ctrl/cmd+s so the editor saves
|
||||
// without closing the modal, matching the remote browser editor
|
||||
const saveOnly = event?.detail?.saveOnly || false;
|
||||
try {
|
||||
isSubmitting = true;
|
||||
if (modalMode === 'create' || modalMode === 'copy') {
|
||||
await onClickCreate(saveOnly);
|
||||
} else {
|
||||
await onClickUpdate(saveOnly);
|
||||
}
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onClickCreate = async (saveOnly = false) => {
|
||||
try {
|
||||
const res = await api.script.create(formValues);
|
||||
if (!res.success) {
|
||||
modalError = res.error;
|
||||
return;
|
||||
}
|
||||
modalError = '';
|
||||
refreshScripts();
|
||||
addToast(saveOnly ? 'Saved script' : 'Created script', 'Success');
|
||||
if (saveOnly) {
|
||||
// keep the modal open and switch to update mode so a further save
|
||||
// edits the same script instead of creating a duplicate
|
||||
formValues = { ...formValues, id: res.data.id };
|
||||
modalMode = 'update';
|
||||
} else {
|
||||
closeModal();
|
||||
}
|
||||
} catch (err) {
|
||||
addToast('Failed to create script', 'Error');
|
||||
console.error('failed to create script:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const onClickUpdate = async (saveOnly = false) => {
|
||||
try {
|
||||
const res = await api.script.update(formValues);
|
||||
if (!res.success) {
|
||||
modalError = res.error;
|
||||
return;
|
||||
}
|
||||
modalError = '';
|
||||
refreshScripts();
|
||||
addToast(saveOnly ? 'Saved script' : 'Updated script', 'Success');
|
||||
if (!saveOnly) {
|
||||
closeModal();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('failed to update script:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteAlert = async (script) => {
|
||||
isDeleteAlertVisible = true;
|
||||
deleteValues.id = script.id;
|
||||
deleteValues.name = script.name;
|
||||
};
|
||||
|
||||
/** @param {string} id */
|
||||
const onClickDelete = async (id) => {
|
||||
const action = api.script.delete(id);
|
||||
action
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
refreshScripts();
|
||||
return;
|
||||
}
|
||||
throw res.error;
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('failed to delete script:', e);
|
||||
});
|
||||
return action;
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
formValues = {
|
||||
id: '',
|
||||
name: '',
|
||||
companyID: contextCompanyID ?? '',
|
||||
script: ''
|
||||
};
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
modalMode = 'create';
|
||||
resetForm();
|
||||
modalError = '';
|
||||
editorKey += 1;
|
||||
isModalVisible = true;
|
||||
};
|
||||
|
||||
/** @param {string} id */
|
||||
const openEditModal = async (id) => {
|
||||
modalMode = 'update';
|
||||
try {
|
||||
showIsLoading();
|
||||
const script = await api.script.getByID(id);
|
||||
if (!script.success) {
|
||||
throw script.error;
|
||||
}
|
||||
const r = globalButtonDisabledAttributes(script, contextCompanyID);
|
||||
if (r.disabled) {
|
||||
hideIsLoading();
|
||||
return;
|
||||
}
|
||||
formValues = script.data;
|
||||
modalError = '';
|
||||
editorKey += 1;
|
||||
isModalVisible = true;
|
||||
} catch (e) {
|
||||
addToast('Failed to get script', 'Error');
|
||||
console.error('failed to get script:', e);
|
||||
} finally {
|
||||
hideIsLoading();
|
||||
}
|
||||
};
|
||||
|
||||
const openCopyModal = async (id) => {
|
||||
modalMode = 'copy';
|
||||
try {
|
||||
showIsLoading();
|
||||
const script = await api.script.getByID(id);
|
||||
if (!script.success) {
|
||||
throw script.error;
|
||||
}
|
||||
formValues = script.data;
|
||||
formValues.id = null;
|
||||
modalError = '';
|
||||
editorKey += 1;
|
||||
isModalVisible = true;
|
||||
} catch (e) {
|
||||
addToast('Failed to get script', 'Error');
|
||||
console.error('failed to get script:', e);
|
||||
} finally {
|
||||
hideIsLoading();
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
isModalVisible = false;
|
||||
modalError = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<HeadTitle title="Scripts" />
|
||||
<main>
|
||||
<Headline>Scripts</Headline>
|
||||
|
||||
{#if featureDisabled}
|
||||
<div class="mt-6 max-w-xl rounded-lg border border-slate-700 bg-slate-800/50 px-6 py-8">
|
||||
<p class="mb-1 text-sm font-semibold text-white">Scripts are not enabled</p>
|
||||
<p class="mb-3 text-sm text-slate-400">
|
||||
This feature is disabled by default for security reasons. When enabled, any operator with
|
||||
access can write scripts that run on the server when campaign events fire — including
|
||||
making outbound HTTP requests. Only enable it on instances where every operator is trusted
|
||||
as a server admin.
|
||||
</p>
|
||||
<p class="mb-4 text-sm text-slate-400">
|
||||
To enable it, set <code class="rounded bg-slate-700 px-1 text-slate-200">enabled: true</code>
|
||||
in the <code class="rounded bg-slate-700 px-1 text-slate-200">script</code> block of
|
||||
<code class="rounded bg-slate-700 px-1 text-slate-200">config.json</code> and restart the service.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<BigButton on:click={openCreateModal}>New script</BigButton>
|
||||
<Table
|
||||
columns={[
|
||||
{ column: 'Name', size: 'large' },
|
||||
...(contextCompanyID ? [{ column: 'Scope', size: 'small' }] : [])
|
||||
]}
|
||||
sortable={['name', ...(contextCompanyID ? ['scope'] : [])]}
|
||||
hasData={!!scripts.length}
|
||||
hasNextPage={scriptsHasNextPage}
|
||||
plural="Scripts"
|
||||
pagination={tableURLParams}
|
||||
isGhost={isTableLoading}
|
||||
>
|
||||
{#each scripts as script}
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<button
|
||||
on:click={() => {
|
||||
openEditModal(script.id);
|
||||
}}
|
||||
{...globalButtonDisabledAttributes(script, contextCompanyID)}
|
||||
title={script.name}
|
||||
>
|
||||
{script.name}
|
||||
</button>
|
||||
</TableCell>
|
||||
{#if contextCompanyID}
|
||||
<TableCellScope companyID={script.companyID} />
|
||||
{/if}
|
||||
<TableCellEmpty />
|
||||
<TableCellAction>
|
||||
<TableDropDownEllipsis>
|
||||
<TableUpdateButton
|
||||
on:click={() => openEditModal(script.id)}
|
||||
{...globalButtonDisabledAttributes(script, contextCompanyID)}
|
||||
/>
|
||||
<TableCopyButton
|
||||
title={'Copy'}
|
||||
on:click={() => openCopyModal(script.id)}
|
||||
{...globalButtonDisabledAttributes(script, contextCompanyID)}
|
||||
/>
|
||||
<TableDeleteButton
|
||||
on:click={() => openDeleteAlert(script)}
|
||||
{...globalButtonDisabledAttributes(script, contextCompanyID)}
|
||||
></TableDeleteButton>
|
||||
</TableDropDownEllipsis>
|
||||
</TableCellAction>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</Table>
|
||||
{/if}
|
||||
|
||||
<Modal
|
||||
headerText={modalText}
|
||||
visible={isModalVisible}
|
||||
fullscreen={true}
|
||||
onClose={closeModal}
|
||||
{isSubmitting}
|
||||
>
|
||||
<FormGrid on:submit={onSubmit} {isSubmitting} {modalMode}>
|
||||
<div class="col-span-3 flex flex-col min-h-0 overflow-hidden px-4 py-4">
|
||||
{#key editorKey}
|
||||
<ScriptEditor bind:name={formValues.name} bind:script={formValues.script} />
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<FormError message={modalError} />
|
||||
|
||||
<FormFooter {closeModal} {isSubmitting} okText={modalMode === 'create' ? 'Create' : 'Update'} />
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
|
||||
<DeleteAlert
|
||||
name={deleteValues.name}
|
||||
onClick={() => onClickDelete(deleteValues.id)}
|
||||
bind:isVisible={isDeleteAlertVisible}
|
||||
></DeleteAlert>
|
||||
</main>
|
||||
Reference in new issue
Block a user