mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-10-12 01:58:53 +02:00
:wqMerge branch 'develop' into test-build
This commit is contained in:
95 files changed
+2272
-421
No files matched your search
@@ -69,7 +69,7 @@ jobs:
|
||||
-w /app/backend \
|
||||
golang@sha256:f86f1a6701e3dcc445fec097a42f78b758f15950ccf032c2d3e54e2754d32fdb `# golang:1.27.1-alpine linux/amd64` \
|
||||
sh -c "apk add --no-cache gcc musl-dev && go build -trimpath \
|
||||
-ldflags='-X github.com/phishingclub/phishingclub/version.hash=ph${{ steps.get_version.outputs.HASH }} -X github.com/phishingclub/phishingclub/version.version=${{ steps.get_version.outputs.VERSION }} -linkmode=external -extldflags=-static' \
|
||||
-ldflags='-s -w -X github.com/phishingclub/phishingclub/version.hash=ph${{ steps.get_version.outputs.HASH }} -X github.com/phishingclub/phishingclub/version.version=${{ steps.get_version.outputs.VERSION }} -linkmode=external -extldflags=-static' \
|
||||
-tags production -o ../build/amd64/phishingclub main.go"
|
||||
|
||||
# build arm64 binary — run natively under QEMU (already set up above)
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
-w /app/backend \
|
||||
golang@sha256:df4c4a0eeb85873e0122c6e2eb1b436f3131576f572505c1ea61954b00fa6460 `# golang:1.27.1-alpine linux/arm64` \
|
||||
sh -c "apk add --no-cache gcc musl-dev && go build -trimpath \
|
||||
-ldflags='-X github.com/phishingclub/phishingclub/version.hash=ph${{ steps.get_version.outputs.HASH }} -X github.com/phishingclub/phishingclub/version.version=${{ steps.get_version.outputs.VERSION }} -linkmode=external -extldflags=-static' \
|
||||
-ldflags='-s -w -X github.com/phishingclub/phishingclub/version.hash=ph${{ steps.get_version.outputs.HASH }} -X github.com/phishingclub/phishingclub/version.version=${{ steps.get_version.outputs.VERSION }} -linkmode=external -extldflags=-static' \
|
||||
-tags production -o ../build/arm64/phishingclub main.go"
|
||||
|
||||
- name: Fix build directory permissions
|
||||
|
||||
@@ -73,19 +73,32 @@ Phishing Club provides a lot of features for simulation and red teaming, such as
|
||||
|
||||
Wrote a blog post or write up about Phishing Club? Tell us about it and we might add it here. Reach out via a GitHub issue, discord or find our email :)
|
||||
|
||||
## Template Development
|
||||
## More from Phishing Club
|
||||
|
||||
### Phishing Template Workbench
|
||||
Open source projects and learning resources built for students and red teamers.
|
||||
|
||||
Speed up your template development with our template workbench tool:
|
||||
### Training Labs
|
||||
|
||||
**[Phishing Template Workbench](https://github.com/phishingclub/templates)** - A developer-focused environment for creating and testing phishing simulation templates.
|
||||
**[Phishing Club Training Labs](https://labs.phishing.club/)** - Free hands on labs for adversary in the middle and remote browser phishing. Steal the session, not just the password. Capture credentials, hijack live sessions, and defeat real defenses like CSP pinning, beacon detection and passkey prompts.
|
||||
|
||||
- **Preview** - Preview templates
|
||||
- **Variable support** - See `{{.FirstName}}`, `{{.Email}}` substitution with realistic sample data
|
||||
### Session Sushi
|
||||
|
||||
**[Session Sushi](https://github.com/phishingclub/session-sushi)** - A zero dependency browser extension for handling cookies, Microsoft 365 OAuth tokens, and Graph API interactions. Built for security professionals.
|
||||
|
||||
- **Cookie Management** - View, import/export as JSON, search, filter, and clear cookies, with incognito session support
|
||||
- **Microsoft 365 Sessions** - Acquire OAuth tokens, store multiple M365 refresh tokens as sessions, refresh manually or automatically, and import/export sessions
|
||||
- **M365 Data Browsers** - Graph, User, Directory, Mailbox, Calendar, OneDrive, SharePoint and Teams
|
||||
|
||||
### Template Workbench
|
||||
|
||||
**[Phishing Template Workbench](https://github.com/phishingclub/templates)** - A developer focused environment for creating and testing phishing simulation templates.
|
||||
|
||||
- **Preview** - See how templates render with test data
|
||||
- **Variable support** - `{{.BaseURL}}`, `{{.Email}}`, `{{.FirstName}}` substitution with realistic sample data
|
||||
- **Naive Responsive Testing** - Preview templates across mobile, tablet, and desktop
|
||||
- **Export Ready** - Compatible with Phishing Club formats
|
||||
- **Included Templates** - Comes with example templates covering common phishing scenarios that you can import and customize
|
||||
- **Naive Email Testing** - Check email templates in Mailpit and score HTML/CSS and SpamAssassin across clients
|
||||
- **Asset Management** - Resolve template assets with fallback to global asset directories
|
||||
- **Export Ready** - Copy processed HTML, download template folders, and export collections compatible with Phishing Club
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
+31
@@ -1,5 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
## [1.43.0] - 2026-10-01
|
||||
- Added scripts feature with support in campaigns, AiTM proxy and Remote Browser
|
||||
- Added custom branding settings
|
||||
- Added AiTM Proxy status engine
|
||||
- Added Remote Browser request() API.
|
||||
- Added Remote Browser state machine API for managing flow in sessions
|
||||
- Added Remote Browser can manipulate headers
|
||||
- Added hotkey for VIM toggle in editor
|
||||
- Added custom EHLO/HELO on SMTP
|
||||
- Added updatable external IP and ASN data for filters
|
||||
- Added ASN look up tool
|
||||
- Added change of campaign trend graph type when over 60 campaigns in view
|
||||
- Updated Remote Browser default template
|
||||
- Fix filters deleted during scheduling update
|
||||
- Fix modal text is now globally selectable
|
||||
- Fix message-id was leaking internals
|
||||
- Fix added implicit TLS on SMTP when using port 465
|
||||
- Fix malformed request when using browser impersonation
|
||||
- Fix AiTM Proxy did not collect cookie when required was set to false
|
||||
- Fix global company assets could not be deleted
|
||||
- Fix improve error message when browser start fails
|
||||
- Fix campaign creation schedule time matched against current time in some timezones
|
||||
- Fix domain listing could 500 via API
|
||||
- Fix Remote Browser 'disableFidoUI' and renamed it - Deprecated disableFioUI
|
||||
- Fix loader location on settings page
|
||||
- Fix send test email search recipient ondemand
|
||||
- Fix alignment on TextFieldSearchSelect component
|
||||
- Fix reduce binary size ~50% by stripping js.map's and debug from build
|
||||
- Deprecated notification for embedded IP filter list
|
||||
- Bumped dependencies
|
||||
|
||||
## [1.42.0] - 2026-09-11
|
||||
- Added position and department statistics to campaigns
|
||||
- Added anonymous campaign mode
|
||||
|
||||
@@ -62,7 +62,7 @@ func setupCertMagic(
|
||||
return nil
|
||||
}
|
||||
// allow on-demand ACME only for domains that use managed TLS (let's encrypt / acme).
|
||||
// own_managed_tls and self_signed_tls domains must never trigger ACME acquisition —
|
||||
// own_managed_tls and self_signed_tls domains must never trigger ACME acquisition,
|
||||
// their certificates are provided manually or generated internally.
|
||||
res := db.
|
||||
Select("id").
|
||||
|
||||
@@ -69,6 +69,7 @@ const (
|
||||
ROUTE_V1_USER_MFA_TOTP = "/api/v1/user/mfa/totp"
|
||||
ROUTE_V1_QR_FROM_TOTP = "/api/v1/qr/totp"
|
||||
ROUTE_V1_QR_URL_TO_HTML = "/api/v1/qr/html"
|
||||
ROUTE_V1_PREVIEW = "/api/v1/preview"
|
||||
// session
|
||||
ROUTE_V1_SESSION_ID = "/api/v1/session/:id"
|
||||
ROUTE_V1_SESSION_PING = "/api/v1/session/ping"
|
||||
@@ -85,7 +86,7 @@ const (
|
||||
ROUTE_V1_COMPANY_REPORT_CONFIG_LOG = "/api/v1/company/report-config/:companyID/log"
|
||||
ROUTE_V1_REPORT_CONFIG_GLOBAL = "/api/v1/report-config"
|
||||
ROUTE_V1_REPORT_CONFIG_SEND = "/api/v1/report-config/send/:id"
|
||||
// scim v2 provisioning endpoints (public — authenticated via bearer token)
|
||||
// scim v2 provisioning endpoints (public, authenticated via bearer token)
|
||||
ROUTE_SCIM_V2_SERVICE_PROVIDER_CONFIG = "/api/v1/scim/v2/:companyID/ServiceProviderConfig"
|
||||
ROUTE_SCIM_V2_RESOURCE_TYPES = "/api/v1/scim/v2/:companyID/ResourceTypes"
|
||||
ROUTE_SCIM_V2_SCHEMAS = "/api/v1/scim/v2/:companyID/Schemas"
|
||||
@@ -207,6 +208,9 @@ const (
|
||||
ROUTE_V1_ASSET_DOMAIN_CONTEXT = "/api/v1/asset/domain/:domain"
|
||||
ROUTE_V1_ASSET_GLOBAL_CONTEXT = "/api/v1/asset/domain/"
|
||||
ROUTE_V1_ASSET_DOMAIN_VIEW = "/api/v1/asset/view/domain/:domain/*path"
|
||||
ROUTE_V1_ASSET_ID_CONTENT = "/api/v1/asset/:id/content"
|
||||
ROUTE_V1_ASSET_ID_MOVE = "/api/v1/asset/:id/move"
|
||||
ROUTE_V1_ASSET_ID_FILE = "/api/v1/asset/:id/file"
|
||||
// attachments
|
||||
ROUTE_V1_ATTACHMENT = "/api/v1/attachment"
|
||||
ROUTE_V1_ATTACHMENT_ID = "/api/v1/attachment/:id"
|
||||
@@ -276,6 +280,12 @@ type administrationServer struct {
|
||||
embedBackendFS *embed.FS
|
||||
certMagicConfig *certmagic.Config
|
||||
softSessionHandler gin.HandlerFunc
|
||||
// the single page app index html with the SvelteKit csp meta tag removed,
|
||||
// and the full policy promoted to a response header, so the whole csp is
|
||||
// delivered server side. nil when the meta could not be read, in which case
|
||||
// index.html is served unchanged and the policy stays in its meta tag.
|
||||
spaIndexHTML []byte
|
||||
spaCSP string
|
||||
}
|
||||
|
||||
// NewAdministrationServer creates a new administration app
|
||||
@@ -312,12 +322,13 @@ func setupRoutes(
|
||||
controllers *Controllers,
|
||||
middleware *Middlewares,
|
||||
) *gin.Engine {
|
||||
// SCIM v2 provisioning endpoints are NOT served here — they live on the
|
||||
// SCIM v2 provisioning endpoints are NOT served here, they live on the
|
||||
// phishing server (app/server.go AssignRoutes), gated to a single global
|
||||
// domain, so the admin port does not need public exposure for SCIM.
|
||||
|
||||
// all other admin routes are protected by the ip allowlist middleware
|
||||
admin := r.Group("/", middleware.IPLimiter)
|
||||
// ip allowlist on all admin routes; NoStore keeps API responses out of the
|
||||
// browser disk cache. static assets are served off the group and stay cacheable.
|
||||
admin := r.Group("/", middleware.IPLimiter, middleware.NoStore)
|
||||
_ = admin
|
||||
|
||||
if !build.Flags.Production {
|
||||
@@ -377,6 +388,8 @@ func setupRoutes(
|
||||
// qr
|
||||
POST(ROUTE_V1_QR_FROM_TOTP, middleware.SessionHandler, controllers.QR.ToTOTPURL).
|
||||
POST(ROUTE_V1_QR_URL_TO_HTML, middleware.SessionHandler, controllers.QR.ToHTML).
|
||||
// preview renders admin authored content isolated with a sandbox CSP
|
||||
POST(ROUTE_V1_PREVIEW, middleware.SessionHandler, controllers.Preview.Render).
|
||||
// company
|
||||
POST(ROUTE_V1_COMPANY, middleware.SessionHandler, controllers.Company.Create).
|
||||
POST(ROUTE_V1_COMPANY_ID, middleware.SessionHandler, controllers.Company.ChangeName).
|
||||
@@ -548,7 +561,7 @@ func setupRoutes(
|
||||
POST(ROUTE_V1_CAMPAIGN_ANONYMIZE_DATA, middleware.SessionHandler, controllers.Campaign.AnonymizeDataByID).
|
||||
DELETE(ROUTE_V1_CAMPAIGN_DEVICE_CODES, middleware.SessionHandler, controllers.Campaign.DeleteDeviceCodesByCampaignID).
|
||||
DELETE(ROUTE_V1_CAMPAIGN_ID, middleware.SessionHandler, controllers.Campaign.DeleteByID).
|
||||
// campaign PDF report — ExtendedTimeout required for headless browser rendering
|
||||
// campaign PDF report, ExtendedTimeout required for headless browser rendering
|
||||
GET(ROUTE_V1_CAMPAIGN_REPORT, middleware.ExtendedTimeout(3*time.Minute), middleware.SessionHandler, controllers.ReportTemplate.GeneratePDFByCampaignID).
|
||||
// report templates
|
||||
GET(ROUTE_V1_REPORT_TEMPLATE, middleware.SessionHandler, controllers.ReportTemplate.GetAll).
|
||||
@@ -568,6 +581,10 @@ func setupRoutes(
|
||||
GET(ROUTE_V1_ASSET_DOMAIN_VIEW, middleware.SessionHandler, controllers.Asset.GetContentByID).
|
||||
GET(ROUTE_V1_ASSET_ID, middleware.SessionHandler, controllers.Asset.GetByID).
|
||||
PATCH(ROUTE_V1_ASSET_ID, middleware.SessionHandler, controllers.Asset.UpdateByID).
|
||||
GET(ROUTE_V1_ASSET_ID_CONTENT, middleware.SessionHandler, controllers.Asset.GetEditableContentByID).
|
||||
PUT(ROUTE_V1_ASSET_ID_CONTENT, middleware.SessionHandler, controllers.Asset.SaveContentByID).
|
||||
PATCH(ROUTE_V1_ASSET_ID_MOVE, middleware.SessionHandler, controllers.Asset.MoveByID).
|
||||
POST(ROUTE_V1_ASSET_ID_FILE, middleware.SessionHandler, controllers.Asset.ReplaceFileByID).
|
||||
GET(ROUTE_V1_ASSET_DOMAIN_CONTEXT, middleware.SessionHandler, controllers.Asset.GetAllForContext).
|
||||
GET(ROUTE_V1_ASSET_GLOBAL_CONTEXT, middleware.SessionHandler, controllers.Asset.GetAllForContext).
|
||||
POST(ROUTE_V1_ASSET, middleware.SessionHandler, controllers.Asset.Create).
|
||||
@@ -835,7 +852,18 @@ func (a *administrationServer) loadEmbeddedFileSystem(
|
||||
embedFS := frontend.GetEmbededFS()
|
||||
// make embedded .html work
|
||||
frontend.LoadHTMLFromEmbedFS(a.router, *embedFS, "build/*.html")
|
||||
// serve favicons only to authenticated users — unauthenticated requests get 404
|
||||
// promote the SvelteKit csp meta tag to a response header so the whole
|
||||
// policy is delivered server side and nothing is left in the document. The
|
||||
// meta holds script-src with the build time script hash plus style-src and
|
||||
// the other resource directives; we add the directives a meta can not carry.
|
||||
// If the meta can not be read we leave index.html and its meta untouched.
|
||||
if policy, indexHTML, ok := frontend.ExtractCSPFromIndex(*embedFS); ok {
|
||||
a.spaIndexHTML = indexHTML
|
||||
a.spaCSP = policy + "; frame-ancestors 'none'; form-action 'self'"
|
||||
} else {
|
||||
a.logger.Warn("could not read csp meta from index.html, serving it unchanged")
|
||||
}
|
||||
// serve favicons only to authenticated users, unauthenticated requests get 404
|
||||
// so the file is not indexable by scanners probing common paths
|
||||
for _, faviconPath := range []string{"/favicon.ico", "/favicon.png"} {
|
||||
fp := faviconPath
|
||||
@@ -854,12 +882,10 @@ func (a *administrationServer) loadEmbeddedFileSystem(
|
||||
if !entry.IsDir() {
|
||||
// special case for the frontpage
|
||||
if path == "index.html" {
|
||||
a.router.GET("/", func(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "build/index.html", nil)
|
||||
})
|
||||
a.router.GET("/", a.serveIndex)
|
||||
continue
|
||||
}
|
||||
// skip favicons — registered separately with session gating
|
||||
// skip favicons, registered separately with session gating
|
||||
if path == "favicon.png" || path == "favicon.ico" {
|
||||
continue
|
||||
}
|
||||
@@ -882,13 +908,24 @@ func (a *administrationServer) loadEmbeddedFileSystem(
|
||||
}
|
||||
}
|
||||
// fall back to the root index.html
|
||||
a.router.NoRoute(func(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "build/index.html", nil)
|
||||
})
|
||||
a.router.NoRoute(a.serveIndex)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// serveIndex serves the single page app shell. When the csp meta was promoted
|
||||
// to a header at startup it serves the stripped html with the full policy header,
|
||||
// which replaces the strict header the security middleware set. Otherwise it
|
||||
// serves the original template and the meta carries the resource directives.
|
||||
func (a *administrationServer) serveIndex(c *gin.Context) {
|
||||
if a.spaIndexHTML != nil {
|
||||
c.Header("Content-Security-Policy", a.spaCSP)
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", a.spaIndexHTML)
|
||||
return
|
||||
}
|
||||
c.HTML(http.StatusOK, "build/index.html", nil)
|
||||
}
|
||||
|
||||
func (a *administrationServer) StartServer(
|
||||
conf *config.Config,
|
||||
) (chan server.StartupMessage, net.Listener, error) {
|
||||
|
||||
@@ -28,6 +28,7 @@ type Controllers struct {
|
||||
CampaignTemplate *controller.CampaignTemplate
|
||||
Campaign *controller.Campaign
|
||||
QR *controller.QRGenerator
|
||||
Preview *controller.Preview
|
||||
APISender *controller.APISender
|
||||
AllowDeny *controller.AllowDeny
|
||||
GeoIP *controller.GeoIP
|
||||
@@ -163,6 +164,9 @@ func NewControllers(
|
||||
qr := &controller.QRGenerator{
|
||||
Common: common,
|
||||
}
|
||||
preview := &controller.Preview{
|
||||
Common: common,
|
||||
}
|
||||
apiSender := &controller.APISender{
|
||||
Common: common,
|
||||
APISenderService: services.APISender,
|
||||
@@ -273,6 +277,7 @@ func NewControllers(
|
||||
CampaignTemplate: campaignTemplate,
|
||||
Campaign: campaign,
|
||||
QR: qr,
|
||||
Preview: preview,
|
||||
APISender: apiSender,
|
||||
AllowDeny: allowDeny,
|
||||
GeoIP: geoIP,
|
||||
|
||||
@@ -15,6 +15,7 @@ type Middlewares struct {
|
||||
LoginRateLimiter gin.HandlerFunc
|
||||
SessionHandler gin.HandlerFunc
|
||||
SoftSessionHandler gin.HandlerFunc
|
||||
NoStore gin.HandlerFunc
|
||||
}
|
||||
|
||||
// NewMiddlewares creates a collection of middlewares
|
||||
@@ -48,6 +49,7 @@ func NewMiddlewares(
|
||||
LoginRateLimiter: loginThrottle,
|
||||
SessionHandler: sessionHandler,
|
||||
SoftSessionHandler: softSessionHandler,
|
||||
NoStore: middleware.NoStore(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1664,7 +1664,7 @@ func (s *Server) checkAndServePhishingPage(
|
||||
// expects when the request arrives at the proxy.
|
||||
urlParam := cTemplate.URLIdentifier.Name.MustGet()
|
||||
|
||||
// collect rewrite_urls rules for the start domain — host-specific first, then global
|
||||
// collect rewrite_urls rules for the start domain, host-specific first, then global
|
||||
var rewriteRules []service.ProxyServiceURLRewriteRule
|
||||
if hostCfg, ok := parsedConfig.Hosts[startDomain]; ok && hostCfg != nil {
|
||||
rewriteRules = append(rewriteRules, hostCfg.RewriteURLs...)
|
||||
|
||||
@@ -182,10 +182,16 @@ func (a *APISender) SendTest(g *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse the optional test values, an empty body means use the defaults
|
||||
var req model.APISenderTestRequest
|
||||
if ok := a.handleParseOptionalRequest(g, &req); !ok {
|
||||
return
|
||||
}
|
||||
data, err := a.APISenderService.SendTest(
|
||||
g.Request.Context(),
|
||||
session,
|
||||
id,
|
||||
&req,
|
||||
)
|
||||
// output the error
|
||||
if err != nil {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
@@ -482,6 +483,197 @@ func (a *Asset) UpdateByID(g *gin.Context) {
|
||||
a.Response.OK(g, gin.H{})
|
||||
}
|
||||
|
||||
// GetEditableContentByID returns an asset's content and whether it can be
|
||||
// edited as text in the editor.
|
||||
func (a *Asset) GetEditableContentByID(g *gin.Context) {
|
||||
// handle session
|
||||
session, _, ok := a.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse request
|
||||
id, ok := a.handleParseIDParam(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// cap the read at the configured upload size so a huge asset is not loaded
|
||||
// into memory just to answer an edit request
|
||||
maxFile, err := a.OptionService.GetOption(g, session, data.OptionKeyMaxFileUploadSizeMB)
|
||||
if ok := a.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
maxBytes := int64(64 << 20)
|
||||
// only a positive option overrides the fallback, so a "0" or negative option
|
||||
// cannot disable the cap and allow an unbounded read.
|
||||
if maxMB, perr := strconv.Atoi(maxFile.Value.String()); perr == nil && maxMB > 0 {
|
||||
maxBytes = int64(maxMB) * 1024 * 1024
|
||||
}
|
||||
// get the content
|
||||
ctx := g.Request.Context()
|
||||
content, editable, err := a.AssetService.GetContentByID(ctx, session, id, maxBytes)
|
||||
if a.fileMissing(g, err) {
|
||||
return
|
||||
}
|
||||
if ok := a.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
a.Response.OK(g, gin.H{
|
||||
"content": base64.StdEncoding.EncodeToString(content),
|
||||
"editable": editable,
|
||||
})
|
||||
}
|
||||
|
||||
// fileMissing answers 404 when the error is a backing file that is gone (a DB
|
||||
// row whose file was removed), instead of letting it fall through to a 500.
|
||||
func (a *Asset) fileMissing(g *gin.Context, err error) bool {
|
||||
if err != nil && os.IsNotExist(err) {
|
||||
a.Response.NotFound(g)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SaveContentByID overwrites a text asset's content from the editor.
|
||||
func (a *Asset) SaveContentByID(g *gin.Context) {
|
||||
// handle session
|
||||
session, _, ok := a.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse request
|
||||
id, ok := a.handleParseIDParam(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// load the size limit first so the request body can be capped before it is
|
||||
// read into memory, instead of only checking the size after buffering it all.
|
||||
maxFile, err := a.OptionService.GetOption(g, session, data.OptionKeyMaxFileUploadSizeMB)
|
||||
if ok := a.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
// cap the body generously above the content limit since JSON adds quoting and
|
||||
// escaping; the exact size is still checked below. a malformed option falls
|
||||
// back to a large cap so valid saves are not blocked at this stage.
|
||||
bodyLimit := int64(64 << 20)
|
||||
if maxMB, perr := strconv.Atoi(maxFile.Value.String()); perr == nil && maxMB > 0 {
|
||||
bodyLimit = int64(maxMB)*1024*1024 + (1 << 20)
|
||||
}
|
||||
g.Request.Body = http.MaxBytesReader(g.Writer, g.Request.Body, bodyLimit)
|
||||
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if ok := a.handleParseRequest(g, &req); !ok {
|
||||
return
|
||||
}
|
||||
content := []byte(req.Content)
|
||||
okSize, err := utils.CompareFileSizeFromString(int64(len(content)), maxFile.Value.String())
|
||||
if err != nil {
|
||||
a.Logger.Errorw("invalid max upload size option", "value", maxFile.Value.String(), "error", err)
|
||||
a.Response.ServerErrorMessage(g, "upload size limit is misconfigured")
|
||||
return
|
||||
}
|
||||
if !okSize {
|
||||
a.Response.ValidationFailed(g, "Content", fmt.Errorf("content is too large"))
|
||||
return
|
||||
}
|
||||
// save the content
|
||||
ctx := g.Request.Context()
|
||||
err = a.AssetService.SaveContentByID(ctx, session, id, content)
|
||||
if a.fileMissing(g, err) {
|
||||
return
|
||||
}
|
||||
if ok := a.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
a.Response.OK(g, gin.H{})
|
||||
}
|
||||
|
||||
// MoveByID renames or moves an asset to a new path within its context.
|
||||
func (a *Asset) MoveByID(g *gin.Context) {
|
||||
// handle session
|
||||
session, _, ok := a.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse request
|
||||
id, ok := a.handleParseIDParam(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if ok := a.handleParseRequest(g, &req); !ok {
|
||||
return
|
||||
}
|
||||
path, err := vo.NewRelativeFilePath(req.Path)
|
||||
if err != nil {
|
||||
a.Logger.Debugw("failed to parse path", "error", err)
|
||||
a.Response.ValidationFailed(g, "Path", err)
|
||||
return
|
||||
}
|
||||
// move the asset
|
||||
ctx := g.Request.Context()
|
||||
err = a.AssetService.MoveByID(ctx, session, id, nullable.NewNullableWithValue(*path))
|
||||
if a.fileMissing(g, err) {
|
||||
return
|
||||
}
|
||||
if ok := a.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
a.Response.OK(g, gin.H{})
|
||||
}
|
||||
|
||||
// ReplaceFileByID replaces an asset's file content with an uploaded file.
|
||||
func (a *Asset) ReplaceFileByID(g *gin.Context) {
|
||||
// handle session
|
||||
session, _, ok := a.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse request
|
||||
id, ok := a.handleParseIDParam(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
file, err := g.FormFile("file")
|
||||
if err != nil {
|
||||
a.Logger.Debugw("no file provided", "error", err)
|
||||
a.Response.BadRequestMessage(g, "No file selected")
|
||||
return
|
||||
}
|
||||
// enforce the max file size option
|
||||
maxFile, err := a.OptionService.GetOption(g, session, data.OptionKeyMaxFileUploadSizeMB)
|
||||
if ok := a.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
okSize, err := utils.CompareFileSizeFromString(file.Size, maxFile.Value.String())
|
||||
if err != nil {
|
||||
a.Logger.Errorw("invalid max upload size option", "value", maxFile.Value.String(), "error", err)
|
||||
a.Response.ServerErrorMessage(g, "upload size limit is misconfigured")
|
||||
return
|
||||
}
|
||||
if !okSize {
|
||||
a.Response.ValidationFailed(
|
||||
g,
|
||||
"File",
|
||||
fmt.Errorf("file '%s' is too large", utils.ReadableFileName(file.Filename)),
|
||||
)
|
||||
return
|
||||
}
|
||||
// replace the file
|
||||
ctx := g.Request.Context()
|
||||
err = a.AssetService.ReplaceFileByID(ctx, session, id, file)
|
||||
if a.fileMissing(g, err) {
|
||||
return
|
||||
}
|
||||
if ok := a.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
a.Response.OK(g, gin.H{})
|
||||
}
|
||||
|
||||
// RemoveByID removes an static asset
|
||||
// if the asset is a directory, it will be removed recursively
|
||||
func (a *Asset) RemoveByID(g *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Preview renders admin authored preview content isolated from the admin origin.
|
||||
type Preview struct {
|
||||
Common
|
||||
}
|
||||
|
||||
// previewMaxBodyBytes caps the posted content so an authenticated request can
|
||||
// not spill an unbounded body to disk during form parsing. A preview is a page
|
||||
// or email template plus inline data urls, well under this.
|
||||
const previewMaxBodyBytes = 20 << 20
|
||||
|
||||
// Render returns the posted content as an html document carrying a sandbox
|
||||
// content security policy. The sandbox flag gives the document an opaque origin,
|
||||
// so scripts in the previewed content can not read the admin session, cookies or
|
||||
// call the API, while the document still gets its own policy and loads its own
|
||||
// external images, styles, fonts and scripts for an accurate preview. The
|
||||
// security headers middleware sets the strict admin policy on every response;
|
||||
// this handler then replaces that header with its own sandbox policy, so the
|
||||
// preview does not inherit it. allow-same-origin is deliberately absent.
|
||||
func (p *Preview) Render(g *gin.Context) {
|
||||
_, _, ok := p.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
g.Request.Body = http.MaxBytesReader(g.Writer, g.Request.Body, previewMaxBodyBytes)
|
||||
content := g.PostForm("content")
|
||||
g.Header(
|
||||
"Content-Security-Policy",
|
||||
"sandbox allow-scripts allow-forms allow-popups allow-modals",
|
||||
)
|
||||
g.Data(http.StatusOK, "text/html; charset=utf-8", []byte(content))
|
||||
}
|
||||
@@ -102,7 +102,7 @@ func (a *activeSession) setBrowserPage(page *rod.Page) {
|
||||
// interest in it. get returns the latest frame (nil until the first arrives); release
|
||||
// drops interest and stops the screencast when the last consumer leaves. Fixed params
|
||||
// (native-resolution ceiling, quality 90) so every consumer sees identical frames
|
||||
// regardless of who else is watching — this is what keeps a victim stream invariant to
|
||||
// regardless of who else is watching, this is what keeps a victim stream invariant to
|
||||
// operator presence.
|
||||
func (a *activeSession) scAcquire(page *rod.Page) (get func() *scFrame, release func()) {
|
||||
tid := page.TargetID
|
||||
@@ -532,7 +532,7 @@ func (m *RemoteBrowserController) RunByID(g *gin.Context) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Drain StreamCh — test runner doesn't serve cropped streams.
|
||||
// Drain StreamCh, test runner doesn't serve cropped streams.
|
||||
go func() {
|
||||
for range runner.StreamCh {
|
||||
}
|
||||
@@ -698,7 +698,7 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) {
|
||||
}
|
||||
sess.victimConnected.Store(true)
|
||||
|
||||
// One active session per campaign recipient — cancel any previous one.
|
||||
// One active session per campaign recipient, cancel any previous one.
|
||||
// Exception: if the previous session is in keepAlive state the script has
|
||||
// parked and is waiting for operator takeover; cancelling it would destroy
|
||||
// a live browser the operator may be about to use. In that case put the
|
||||
@@ -714,7 +714,7 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) {
|
||||
}
|
||||
defer func() {
|
||||
// For keepAlive sessions the runner is still parked waiting for the
|
||||
// operator — do not cancel or remove it here. CloseLiveSession handles
|
||||
// operator, do not cancel or remove it here. CloseLiveSession handles
|
||||
// cleanup when the operator explicitly ends the session.
|
||||
if !sess.isKeepAlive.Load() {
|
||||
m.RemoteBrowserService.CompareAndDeleteSession(crIDStr, sess)
|
||||
@@ -785,7 +785,7 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) {
|
||||
case runner.Incoming <- remotebrowser.IncomingMsg{Event: "disconnect"}:
|
||||
default:
|
||||
}
|
||||
// keepAlive: browser is parked for operator takeover — a victim
|
||||
// keepAlive: browser is parked for operator takeover, a victim
|
||||
// disconnect must not kill the session, the operator still needs it.
|
||||
if !sess.isKeepAlive.Load() {
|
||||
cancel()
|
||||
@@ -1569,7 +1569,7 @@ func (m *RemoteBrowserController) StreamLiveSession(g *gin.Context) {
|
||||
// Editor test runs only: size the target to the admin's own resolution
|
||||
// so remote control is pixel-accurate instead of the headless 800x600
|
||||
// default. Gated on isTest so a live recipient's viewport is never
|
||||
// touched here — the victim owns the shared target's size, and resizing
|
||||
// touched here, the victim owns the shared target's size, and resizing
|
||||
// it under an operator would change what the recipient sees.
|
||||
if controlMode && sess.isTest && header.Width > 0 && header.Height > 0 {
|
||||
if p := getActivePage(); p != nil {
|
||||
@@ -2228,7 +2228,7 @@ func (m *RemoteBrowserController) runNamedStream(
|
||||
|
||||
// Subscribe to the session's shared screencast for this page. Victim frames are
|
||||
// always cropped from this one screencast, so the stream is identical whether or not
|
||||
// an operator is also viewing — no operator-presence leak. releaseSC stops the
|
||||
// an operator is also viewing, no operator-presence leak. releaseSC stops the
|
||||
// screencast once this and every other consumer of the target has gone.
|
||||
getFrame, releaseSC := sess.scAcquire(page)
|
||||
defer releaseSC()
|
||||
|
||||
@@ -430,7 +430,7 @@ func (c *Scim) DeleteGroup(g *gin.Context) {
|
||||
return
|
||||
}
|
||||
go c.ScimService.UpdateLastSync(context.Background(), result.Config)
|
||||
// rfc 7644 §3.6 — successful DELETE returns 204 No Content
|
||||
// rfc 7644 §3.6, successful DELETE returns 204 No Content
|
||||
g.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -634,7 +634,7 @@ func (c *Scim) DeleteUser(g *gin.Context) {
|
||||
return
|
||||
}
|
||||
go c.ScimService.UpdateLastSync(context.Background(), result.Config)
|
||||
// RFC 7644 §3.6 — successful DELETE returns 204 No Content
|
||||
// RFC 7644 §3.6, successful DELETE returns 204 No Content
|
||||
g.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,26 @@ func (c *Common) handleGlobalAuthorization(
|
||||
func (c *Common) handleParseRequest(
|
||||
g *gin.Context,
|
||||
req any,
|
||||
) bool {
|
||||
return c.parseRequest(g, req, false)
|
||||
}
|
||||
|
||||
// handleParseOptionalRequest parses a request body like handleParseRequest but
|
||||
// treats an empty body as no input, leaving req at its zero value. Use it for
|
||||
// endpoints where every field is optional.
|
||||
func (c *Common) handleParseOptionalRequest(
|
||||
g *gin.Context,
|
||||
req any,
|
||||
) bool {
|
||||
return c.parseRequest(g, req, true)
|
||||
}
|
||||
|
||||
// parseRequest reads and unmarshals the request body. When allowEmpty is true an
|
||||
// empty body is accepted and leaves req untouched.
|
||||
func (c *Common) parseRequest(
|
||||
g *gin.Context,
|
||||
req any,
|
||||
allowEmpty bool,
|
||||
) bool {
|
||||
body, err := io.ReadAll(g.Request.Body)
|
||||
if err != nil {
|
||||
@@ -92,6 +112,9 @@ func (c *Common) handleParseRequest(
|
||||
c.Response.BadRequest(g)
|
||||
return false
|
||||
}
|
||||
if allowEmpty && len(bytes.TrimSpace(body)) == 0 {
|
||||
return true
|
||||
}
|
||||
if err := utils.Unmarshal(body, &req); err != nil {
|
||||
c.Logger.Debugw("failed to parse request",
|
||||
"error", err,
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
streamLastStart[m.name] = m;
|
||||
var st = streams[m.name];
|
||||
if (st) {
|
||||
// Stream already mounted — this is a resize/reposition update only.
|
||||
// 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);
|
||||
|
||||
@@ -2,6 +2,7 @@ package frontend
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"regexp"
|
||||
@@ -10,6 +11,41 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// cspMetaRe matches the content security policy meta tag SvelteKit bakes into
|
||||
// index.html during a hash mode build.
|
||||
var cspMetaRe = regexp.MustCompile(`(?is)<meta[^>]+http-equiv=["']content-security-policy["'][^>]*>`)
|
||||
|
||||
// cspContentRe pulls the policy out of the meta tag's content attribute. A csp
|
||||
// policy contains single quotes but never double quotes, so the content
|
||||
// attribute is double quoted and its value runs to the next double quote.
|
||||
var cspContentRe = regexp.MustCompile(`(?is)content="([^"]*)"`)
|
||||
|
||||
// ExtractCSPFromIndex reads build/index.html from the embedded file system,
|
||||
// pulls the policy out of the SvelteKit content security policy meta tag, and
|
||||
// returns that policy, the html with the meta tag removed, and whether a tag was
|
||||
// found. When nothing is found it returns the original bytes and false so the
|
||||
// caller can fall back to serving the document unchanged.
|
||||
func ExtractCSPFromIndex(embedFS embed.FS) (policy string, indexHTML []byte, ok bool) {
|
||||
data, err := embedFS.ReadFile("build/index.html")
|
||||
if err != nil {
|
||||
return "", nil, false
|
||||
}
|
||||
tag := cspMetaRe.Find(data)
|
||||
if tag == nil {
|
||||
return "", data, false
|
||||
}
|
||||
m := cspContentRe.FindSubmatch(tag)
|
||||
if m == nil {
|
||||
return "", data, false
|
||||
}
|
||||
policy = strings.TrimSpace(html.UnescapeString(string(m[1])))
|
||||
if policy == "" {
|
||||
return "", data, false
|
||||
}
|
||||
stripped := cspMetaRe.ReplaceAll(data, []byte(""))
|
||||
return policy, stripped, true
|
||||
}
|
||||
|
||||
// The version of gin I used when writting this, did not support using embeded files as html
|
||||
// so I found this solution on good old https://stackoverflow.com/questions/26537299/golang-gin-framework-status-code-without-message-body
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package middleware
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// NoStore keeps responses out of the browser disk cache. A handler may set
|
||||
// its own Cache-Control afterwards to override it.
|
||||
func NoStore() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func (r *KeyRateLimiter) GetLimiter(key string) *rate.Limiter {
|
||||
entry.lastAccess.Store(time.Now().UnixNano())
|
||||
|
||||
// LoadOrStore atomically either stores our new entry or returns the
|
||||
// existing one — correctly handles both the common case and concurrent
|
||||
// existing one, correctly handles both the common case and concurrent
|
||||
// goroutines racing to create an entry for the same key
|
||||
actual, loaded := r.key.LoadOrStore(key, entry)
|
||||
if loaded {
|
||||
|
||||
@@ -4,11 +4,23 @@ import "github.com/gin-gonic/gin"
|
||||
|
||||
func SecurityHeaders() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// too many issues with previewing resources
|
||||
// c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; frame-src data:; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
|
||||
// The built single page app ships its own CSP as a meta tag inside
|
||||
// index.html: SvelteKit hash mode hashes its inline bootstrap script at
|
||||
// build time and bakes the script and its matching hash together, which a
|
||||
// static file can only carry in the document, not as a response header.
|
||||
// That meta holds the resource directives (script-src with the hash,
|
||||
// style-src, img-src and so on). This header adds only what a meta tag can
|
||||
// not express, frame-ancestors and form-action, plus object-src and
|
||||
// base-uri. It sets no script-src or style-src, so it never conflicts with
|
||||
// the baked hash. It is applied to every admin response, including the
|
||||
// single page app fallback on unmatched paths. The preview handler
|
||||
// replaces this header with its own sandbox policy on its authenticated
|
||||
// response, so the preview stays frameable while every other path keeps
|
||||
// frame-ancestors none.
|
||||
c.Header("Content-Security-Policy", "frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self'")
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func handleAPISession(
|
||||
found := false
|
||||
// compare each key using constant-time comparison to avoid hash timing leaks.
|
||||
// early exit via break is acceptable here because the key slice is derived from
|
||||
// a go map, which randomizes iteration order on every call — an attacker cannot
|
||||
// a go map, which randomizes iteration order on every call, an attacker cannot
|
||||
// correlate iteration count to a stable key position across requests.
|
||||
var rApiUser *model.APIUser
|
||||
for _, apiUser := range apiUsers {
|
||||
|
||||
@@ -37,6 +37,25 @@ type APISender struct {
|
||||
ExpectedResponseBody nullable.Nullable[vo.OptionalString1MB] `json:"expectedResponseBody"`
|
||||
}
|
||||
|
||||
// APISenderTestRequest holds optional values the user sets when running a
|
||||
// test send. A nil or empty field falls back to the built in default.
|
||||
type APISenderTestRequest struct {
|
||||
Email *string `json:"email"`
|
||||
FirstName *string `json:"firstName"`
|
||||
LastName *string `json:"lastName"`
|
||||
Subject *string `json:"subject"`
|
||||
Content *string `json:"content"`
|
||||
FromEmail *string `json:"fromEmail"`
|
||||
FromName *string `json:"fromName"`
|
||||
Phone *string `json:"phone"`
|
||||
ExtraIdentifier *string `json:"extraIdentifier"`
|
||||
Position *string `json:"position"`
|
||||
Department *string `json:"department"`
|
||||
City *string `json:"city"`
|
||||
Country *string `json:"country"`
|
||||
Misc *string `json:"misc"`
|
||||
}
|
||||
|
||||
// Validate checks if the API sender has a valid state
|
||||
func (a *APISender) Validate() error {
|
||||
if err := validate.NullableFieldRequired("name", a.Name); err != nil {
|
||||
|
||||
@@ -250,7 +250,7 @@ func (c *Campaign) Validate() error {
|
||||
return validate.WrapErrorWithField(errors.New("anonymize at must be after close date"), "AnonymizeAt")
|
||||
}
|
||||
}
|
||||
if v, err := c.SendEndAt.Get(); err != nil {
|
||||
if v, err := c.SendEndAt.Get(); err == nil {
|
||||
if anonymizeAt.Before(v) {
|
||||
return validate.WrapErrorWithField(errors.New("anonymize at must be after end date"), "AnonymizeAt")
|
||||
}
|
||||
|
||||
@@ -170,6 +170,14 @@ func (c *CampaignTemplate) Validate() error {
|
||||
)
|
||||
}
|
||||
|
||||
// the redirect url is shown as a link in the admin area, so reject any
|
||||
// scheme other than http or https to keep a javascript url out of an href
|
||||
if v, err := c.AfterLandingPageRedirectURL.Get(); err == nil && v.String() != "" {
|
||||
if err := validate.ErrorIfInvalidURL(v.String()); err != nil {
|
||||
return validate.WrapErrorWithField(err, "afterLandingPageRedirectURL")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,19 @@ func (o *OAuthProvider) Validate() error {
|
||||
if err := validate.NullableFieldRequired("clientSecret", o.ClientSecret); err != nil {
|
||||
return err
|
||||
}
|
||||
// the auth url is opened in the operator browser during authorization, so
|
||||
// reject any scheme other than http or https to keep a javascript url out.
|
||||
// the token url is only called server side but is held to the same rule.
|
||||
if v, err := o.AuthURL.Get(); err == nil {
|
||||
if err := validate.ErrorIfNotHTTPURL(v.String()); err != nil {
|
||||
return validate.WrapErrorWithField(err, "authURL")
|
||||
}
|
||||
}
|
||||
if v, err := o.TokenURL.Get(); err == nil {
|
||||
if err := validate.ErrorIfNotHTTPURL(v.String()); err != nil {
|
||||
return validate.WrapErrorWithField(err, "tokenURL")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2811,7 +2811,7 @@ func (m *ProxyHandler) rewriteRuleMatchesRequest(rule service.ProxyServiceReplac
|
||||
}
|
||||
if rule.Path != "" {
|
||||
if rule.PathRe == nil {
|
||||
// path was set but failed to compile — skip rule safely
|
||||
// path was set but failed to compile, skip rule safely
|
||||
return false
|
||||
}
|
||||
if !rule.PathRe.MatchString(req.URL.Path) {
|
||||
@@ -2889,11 +2889,11 @@ func (m *ProxyHandler) applyReplacementWithVariables(body []byte, replacement se
|
||||
case "dom":
|
||||
return m.applyDomReplacement(body, interpolatedReplacement, sessionID, contentType)
|
||||
case "header":
|
||||
// header engine operates directly on http.Header — it cannot be used in a body rewrite context.
|
||||
// header engine operates directly on http.Header, it cannot be used in a body rewrite context.
|
||||
// callers that handle headers (applyCustomResponseHeaderReplacementsWithVariables,
|
||||
// applyCustomResponseHeaderReplacementsWithoutSession, applyEarlyRequestHeaderReplacements)
|
||||
// intercept this engine before reaching here.
|
||||
m.logger.Errorw("header engine used in a non-header rewrite context — use from: request_header or response_header", "sessionID", sessionID, "rule", replacement.Name)
|
||||
m.logger.Errorw("header engine used in a non-header rewrite context, use from: request_header or response_header", "sessionID", sessionID, "rule", replacement.Name)
|
||||
return body
|
||||
default:
|
||||
m.logger.Errorw("unsupported replacement engine", "engine", engine, "sessionID", sessionID)
|
||||
@@ -3840,7 +3840,7 @@ func (m *ProxyHandler) buildCampaignFlowRedirectURL(session *service.ProxySessio
|
||||
}
|
||||
|
||||
// createCampaignInfoEvent saves captured data as a low-priority info event instead of a submit event.
|
||||
// The capture still participates in completion tracking and flow progression normally — only the
|
||||
// The capture still participates in completion tracking and flow progression normally, only the
|
||||
// saved event type differs.
|
||||
func (m *ProxyHandler) createCampaignInfoEvent(session *service.ProxySession, capturedData map[string]interface{}, req *http.Request, originalUserAgent string) {
|
||||
if session.CampaignID == nil || session.CampaignRecipientID == nil {
|
||||
@@ -4785,7 +4785,7 @@ func (m *ProxyHandler) checkAndServeEvasionPage(req *http.Request, reqCtx *Reque
|
||||
}
|
||||
|
||||
// build the post-evasion redirect url using the start url as the source of truth for
|
||||
// path and query params — the incoming request only has the campaign id param, not the
|
||||
// path and query params, the incoming request only has the campaign id param, not the
|
||||
// real start url params (client_id, redirect_uri etc.). apply rewrite_urls rules so the
|
||||
// victim sees the friendly path and remapped param names, not the real ones.
|
||||
var startPath string
|
||||
@@ -4805,7 +4805,7 @@ func (m *ProxyHandler) checkAndServeEvasionPage(req *http.Request, reqCtx *Reque
|
||||
startQuery = url.Values{}
|
||||
}
|
||||
|
||||
// collect rewrite_urls rules — host-specific first, then global
|
||||
// collect rewrite_urls rules, host-specific first, then global
|
||||
var rewriteRules []service.ProxyServiceURLRewriteRule
|
||||
if hostCfg, ok := reqCtx.ProxyConfig.Hosts[reqCtx.TargetDomain]; ok && hostCfg != nil {
|
||||
rewriteRules = append(rewriteRules, hostCfg.RewriteURLs...)
|
||||
|
||||
@@ -82,7 +82,7 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page,
|
||||
// frameCtxs tracks execution contexts for same-process sub-frames, keyed by context ID.
|
||||
// Value is [3]string{frameId, origin, name}. Populated on demand by resolveSameOriginFrames
|
||||
// via Page.createIsolatedWorld (not Runtime execution-context events, which would enable
|
||||
// the Runtime domain — a CDP tell). frameWorlds caches which frame already has a world so
|
||||
// the Runtime domain, a CDP tell). frameWorlds caches which frame already has a world so
|
||||
// we don't recreate one every scan; navigation invalidates the entry.
|
||||
var frameCtxs sync.Map // proto.RuntimeExecutionContextID → [3]string{frameId, origin, name}
|
||||
var frameWorlds sync.Map // frameId string → proto.RuntimeExecutionContextID
|
||||
@@ -125,7 +125,7 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page,
|
||||
|
||||
// IMPORTANT (opsec): do NOT subscribe to any Runtime.* events here. rod auto-enables
|
||||
// a domain for every event type passed to EachEvent, and enabling the Runtime domain
|
||||
// is a detectable CDP tell — the console/Error.stack serialization leak that trips
|
||||
// is a detectable CDP tell, the console/Error.stack serialization leak that trips
|
||||
// isAutomatedWithCDP. We only track OOPIF targets (Target.*, no such leak); same-origin
|
||||
// sub-frame contexts are resolved on demand via Page.createIsolatedWorld instead.
|
||||
waitFrameEvt := page.EachEvent(
|
||||
@@ -151,7 +151,7 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page,
|
||||
return false
|
||||
},
|
||||
// Page.* is safe to subscribe to (no Runtime-enable tell). On navigation a frame's
|
||||
// isolated world is destroyed, so drop the cache entry — resolveSameOriginFrames
|
||||
// isolated world is destroyed, so drop the cache entry, resolveSameOriginFrames
|
||||
// recreates it on the next scan.
|
||||
func(e *proto.PageFrameNavigated) bool {
|
||||
if e.Frame == nil {
|
||||
@@ -176,7 +176,7 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page,
|
||||
// same-process child frame (Page.createIsolatedWorld). This replaces the old Runtime
|
||||
// execution-context event tracking, which enabled the Runtime domain (a CDP tell).
|
||||
// OOPIF frames live in another process, so createIsolatedWorld fails for them and they
|
||||
// are skipped — those are scanned separately via framePages. Worlds are cached per
|
||||
// are skipped, those are scanned separately via framePages. Worlds are cached per
|
||||
// frame (frameWorlds) and invalidated on navigation, so scanning does not churn worlds.
|
||||
// Throttled so a tight poll loop issues at most one getFrameTree per interval.
|
||||
var lastFrameResolve time.Time
|
||||
|
||||
@@ -343,7 +343,7 @@ var knownGoErrors = []struct {
|
||||
substr string
|
||||
message string
|
||||
}{
|
||||
{"connection refused", "browser connection refused — is Chrome running?"},
|
||||
{"connection refused", "browser connection refused, is Chrome running?"},
|
||||
{"use of closed network connection", "browser connection closed unexpectedly"},
|
||||
{"i/o timeout", "browser CDP connection timed out"},
|
||||
{"EOF", "browser disconnected unexpectedly"},
|
||||
@@ -512,8 +512,8 @@ func (r *Runner) Run(ctx context.Context) error {
|
||||
vm := goja.New()
|
||||
|
||||
// Interrupt the JS VM on termination. Two cases:
|
||||
// DeadlineExceeded — real timeout: interrupt immediately.
|
||||
// Canceled — either keepAlive() cancelled the script timeout to
|
||||
// DeadlineExceeded, real timeout: interrupt immediately.
|
||||
// Canceled , either keepAlive() cancelled the script timeout to
|
||||
// park the session, or the operator cancelled. Either
|
||||
// way wait for the outer context so we only interrupt
|
||||
// when the operator actually ends the session.
|
||||
@@ -1054,16 +1054,16 @@ func (r *Runner) Run(ctx context.Context) error {
|
||||
}
|
||||
// Note: console.* are deliberately left native. Replacing them with noop
|
||||
// functions (to suppress page logging) makes console.log.toString() non-native,
|
||||
// which trips TamperedFunctions. The console is not captured anyway — we never
|
||||
// which trips TamperedFunctions. The console is not captured anyway, we never
|
||||
// enable the Runtime domain (see the opsec note in browser.go), so no page
|
||||
// console output reaches the server regardless.
|
||||
}
|
||||
|
||||
// Identity handling differs by mode:
|
||||
// local — the UA is already set via the --user-agent launch flag above,
|
||||
// local , the UA is already set via the --user-agent launch flag above,
|
||||
// which keeps every context (including service workers) consistent
|
||||
// and preserves Chrome's native client hints. No CDP override here.
|
||||
// remote — no launch flag is possible, so align the main frame via CDP:
|
||||
// remote, no launch flag is possible, so align the main frame via CDP:
|
||||
// fetch the real UA, strip any "HeadlessChrome" token, and set a
|
||||
// matching platform and client-hint metadata.
|
||||
if opts.Remote != "" {
|
||||
@@ -1124,7 +1124,7 @@ func (r *Runner) Run(ctx context.Context) error {
|
||||
_, err := fn(goja.Undefined(), tmpSession)
|
||||
if err != nil {
|
||||
// If our own timeout context expired, return false instead of
|
||||
// propagating — lets callers branch without try/catch.
|
||||
// propagating, lets callers branch without try/catch.
|
||||
if tCtx.Err() != nil {
|
||||
return vm.ToValue(false)
|
||||
}
|
||||
@@ -1163,14 +1163,14 @@ func (r *Runner) Run(ctx context.Context) error {
|
||||
Time: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
r.keepAliveActive.Store(true)
|
||||
timeoutCancel() // release script timeout — operator controls lifetime now
|
||||
timeoutCancel() // release script timeout, operator controls lifetime now
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
// Event-driven API: s.on(event, fn) + s.listen() + s.done()
|
||||
// Built-in lifecycle events emitted by the server:
|
||||
// "disconnect" — victim WebSocket connection dropped
|
||||
// "navigate" — main frame navigated; data: { url: string }
|
||||
// "disconnect", victim WebSocket connection dropped
|
||||
// "navigate" , main frame navigated; data: { url: string }
|
||||
handlers := map[string]goja.Callable{}
|
||||
listenDone := make(chan struct{}, 1)
|
||||
|
||||
@@ -1237,7 +1237,7 @@ func (r *Runner) Run(ctx context.Context) error {
|
||||
}
|
||||
})
|
||||
|
||||
// s.stream(selector, name) — non-blocking; returns {stop()} to end the stream.
|
||||
// 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 {
|
||||
@@ -1658,7 +1658,7 @@ func (r *Runner) Run(ctx context.Context) error {
|
||||
if err != nil {
|
||||
// errors.Is/As traverse goja.Exception.Unwrap(), which extracts the Go error
|
||||
// stored in the "value" property of a GoError object. Do NOT use
|
||||
// ex.Value().Export().(error) — that returns map[string]interface{} for JS
|
||||
// ex.Value().Export().(error), that returns map[string]interface{} for JS
|
||||
// objects and always fails the type assertion.
|
||||
var stopErr scriptStopError
|
||||
if errors.As(err, &stopErr) {
|
||||
@@ -1667,7 +1667,7 @@ func (r *Runner) Run(ctx context.Context) error {
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
// Check whether the *global* script timeout fired. If ctx.Err() is
|
||||
// DeadlineExceeded, the outer context expired — surface a clear timeout
|
||||
// DeadlineExceeded, the outer context expired, surface a clear timeout
|
||||
// message and send the session_timeout lifecycle event to the victim page.
|
||||
// Otherwise this is a per-operation timeout (withTimeout, queryTimeout).
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
|
||||
@@ -2320,12 +2320,18 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) {
|
||||
}
|
||||
}
|
||||
allowDeny := []*model.AllowDeny{}
|
||||
allowDenyIDs := []*uuid.UUID{}
|
||||
// AllowDenyIDs stays unspecified unless the allow/deny association was
|
||||
// preloaded. A scalar-only UpdateByID (notable event, close) reads a campaign
|
||||
// without that preload and must leave the filter lists alone; a specified but
|
||||
// empty value makes UpdateByID delete every join row.
|
||||
var allowDenyIDs nullable.Nullable[[]*uuid.UUID]
|
||||
if row.AllowDeny != nil {
|
||||
ids := []*uuid.UUID{}
|
||||
for _, ad := range row.AllowDeny {
|
||||
allowDeny = append(allowDeny, ToAllowDeny(ad))
|
||||
allowDenyIDs = append(allowDenyIDs, ad.ID)
|
||||
ids = append(ids, ad.ID)
|
||||
}
|
||||
allowDenyIDs.Set(ids)
|
||||
}
|
||||
var denyPage *model.Page
|
||||
if row.DenyPage != nil {
|
||||
@@ -2450,7 +2456,7 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) {
|
||||
RecipientGroups: recipientGroups,
|
||||
RecipientGroupIDs: nullable.NewNullableWithValue(recipientGroupIDs),
|
||||
AllowDeny: allowDeny,
|
||||
AllowDenyIDs: nullable.NewNullableWithValue(allowDenyIDs),
|
||||
AllowDenyIDs: allowDenyIDs,
|
||||
DenyPage: denyPage,
|
||||
DenyPageID: denyPageID,
|
||||
EvasionPage: evasionPage,
|
||||
|
||||
@@ -228,7 +228,7 @@ func (r *CompanyScimConfig) DeleteByCompanyID(
|
||||
}
|
||||
|
||||
// ToCompanyScimConfig maps a database row to the business model.
|
||||
// the token field is intentionally left empty — it is never read back from storage.
|
||||
// the token field is intentionally left empty, it is never read back from storage.
|
||||
func ToCompanyScimConfig(row *database.CompanyScimConfig) *model.CompanyScimConfig {
|
||||
id := nullable.NewNullableWithValue(*row.ID)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// runLog entry shape so the editor renders both the same way.
|
||||
// - type "log": Message (+ optional Data)
|
||||
// - type "info": Message
|
||||
// - type "event": Key (event name) + Value (event data) — an emitEvent call
|
||||
// - type "event": Key (event name) + Value (event data), an emitEvent call
|
||||
// - type "error": Message
|
||||
// - type "done": end marker
|
||||
type TestEntry struct {
|
||||
|
||||
+150
-18
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
@@ -400,6 +401,7 @@ func (a *APISender) SendTest(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
id *uuid.UUID,
|
||||
req *model.APISenderTestRequest,
|
||||
) (*APISenderTestResponse, error) {
|
||||
ae := NewAuditEvent("ApiSender.SendTest", session)
|
||||
ae.Details["id"] = id.String()
|
||||
@@ -436,26 +438,156 @@ func (a *APISender) SendTest(
|
||||
oauthAccessToken = token
|
||||
a.Logger.Debugw("got oauth access token for api test request", "oauthProviderID", oauthProviderID)
|
||||
}
|
||||
emailRaw := "bob@enterprise.test"
|
||||
email := *vo.NewEmailMust(emailRaw)
|
||||
// resolve the test values, a nil or empty override falls back to the default
|
||||
if req == nil {
|
||||
req = &model.APISenderTestRequest{}
|
||||
}
|
||||
const (
|
||||
defFromEmail = "bob@enterprise.test"
|
||||
defFromName = "Bob"
|
||||
defEmail = "bob@enterprise.test"
|
||||
defFirstName = "Bob"
|
||||
defLastName = "Test"
|
||||
defSubject = "Test Email Subject"
|
||||
defContent = "Hi {{.FirstName}},\n\nThis is a test email.\n\nBest,\nBob"
|
||||
defPhone = "+1234567890"
|
||||
defExtraID = "extra-test-identifier"
|
||||
defPosition = "Lead API Tester"
|
||||
defDept = "Research and Development"
|
||||
defCity = "Odin"
|
||||
defCountry = "Denmark"
|
||||
defMisc = "This is a test recipient"
|
||||
)
|
||||
pick := func(p *string, def string) string {
|
||||
if p != nil && *p != "" {
|
||||
return *p
|
||||
}
|
||||
return def
|
||||
}
|
||||
// trim the from fields, they are composed into one header below where
|
||||
// surrounding whitespace would otherwise fail validation
|
||||
fromEmailRaw := strings.TrimSpace(pick(req.FromEmail, defFromEmail))
|
||||
fromNameRaw := strings.TrimSpace(pick(req.FromName, defFromName))
|
||||
recipientEmailRaw := pick(req.Email, defEmail)
|
||||
firstNameRaw := pick(req.FirstName, defFirstName)
|
||||
lastNameRaw := pick(req.LastName, defLastName)
|
||||
subjectRaw := pick(req.Subject, defSubject)
|
||||
contentRaw := pick(req.Content, defContent)
|
||||
phoneRaw := pick(req.Phone, defPhone)
|
||||
extraIdentifierRaw := pick(req.ExtraIdentifier, defExtraID)
|
||||
positionRaw := pick(req.Position, defPosition)
|
||||
departmentRaw := pick(req.Department, defDept)
|
||||
cityRaw := pick(req.City, defCity)
|
||||
countryRaw := pick(req.Country, defCountry)
|
||||
miscRaw := pick(req.Misc, defMisc)
|
||||
|
||||
recipientEmail, err := vo.NewEmail(recipientEmailRaw)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(fmt.Errorf("email: %w", err))
|
||||
}
|
||||
envelopeFrom, err := vo.NewMailEnvelopeFrom(fromEmailRaw)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(fmt.Errorf("from email: %w", err))
|
||||
}
|
||||
// build the header via mail.Address so a display name with a comma or other
|
||||
// special character is quoted correctly, fromEmail is already validated above
|
||||
headerFromRaw := (&mail.Address{Name: fromNameRaw, Address: fromEmailRaw}).String()
|
||||
headerFrom, err := vo.NewEmail(headerFromRaw)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(fmt.Errorf("from name or from email: %w", err))
|
||||
}
|
||||
subject, err := vo.NewOptionalString255(subjectRaw)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(fmt.Errorf("subject: %w", err))
|
||||
}
|
||||
content, err := vo.NewOptionalString1MB(contentRaw)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(fmt.Errorf("content: %w", err))
|
||||
}
|
||||
firstName, err := vo.NewOptionalString127(firstNameRaw)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(fmt.Errorf("first name: %w", err))
|
||||
}
|
||||
lastName, err := vo.NewOptionalString127(lastNameRaw)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(fmt.Errorf("last name: %w", err))
|
||||
}
|
||||
phone, err := vo.NewOptionalString127(phoneRaw)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(fmt.Errorf("phone: %w", err))
|
||||
}
|
||||
extraIdentifier, err := vo.NewOptionalString127(extraIdentifierRaw)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(fmt.Errorf("extra identifier: %w", err))
|
||||
}
|
||||
position, err := vo.NewOptionalString127(positionRaw)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(fmt.Errorf("position: %w", err))
|
||||
}
|
||||
department, err := vo.NewOptionalString127(departmentRaw)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(fmt.Errorf("department: %w", err))
|
||||
}
|
||||
city, err := vo.NewOptionalString127(cityRaw)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(fmt.Errorf("city: %w", err))
|
||||
}
|
||||
country, err := vo.NewOptionalString127(countryRaw)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(fmt.Errorf("country: %w", err))
|
||||
}
|
||||
misc, err := vo.NewOptionalString127(miscRaw)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(fmt.Errorf("misc: %w", err))
|
||||
}
|
||||
|
||||
// record which values differ from the default, names only to avoid logging content
|
||||
isOverride := func(p *string, def string) bool {
|
||||
return p != nil && strings.TrimSpace(*p) != "" && strings.TrimSpace(*p) != def
|
||||
}
|
||||
overridden := []string{}
|
||||
for _, f := range []struct {
|
||||
name string
|
||||
p *string
|
||||
def string
|
||||
}{
|
||||
{"fromEmail", req.FromEmail, defFromEmail},
|
||||
{"fromName", req.FromName, defFromName},
|
||||
{"email", req.Email, defEmail},
|
||||
{"firstName", req.FirstName, defFirstName},
|
||||
{"lastName", req.LastName, defLastName},
|
||||
{"subject", req.Subject, defSubject},
|
||||
{"content", req.Content, defContent},
|
||||
{"phone", req.Phone, defPhone},
|
||||
{"extraIdentifier", req.ExtraIdentifier, defExtraID},
|
||||
{"position", req.Position, defPosition},
|
||||
{"department", req.Department, defDept},
|
||||
{"city", req.City, defCity},
|
||||
{"country", req.Country, defCountry},
|
||||
{"misc", req.Misc, defMisc},
|
||||
} {
|
||||
if isOverride(f.p, f.def) {
|
||||
overridden = append(overridden, f.name)
|
||||
}
|
||||
}
|
||||
ae.Details["overridden"] = strings.Join(overridden, ",")
|
||||
|
||||
cid := nullable.NewNullableWithValue(uuid.New())
|
||||
testEmail := &model.Email{
|
||||
Name: nullable.NewNullableWithValue(
|
||||
*vo.NewString64Must("Test Email"),
|
||||
),
|
||||
MailEnvelopeFrom: nullable.NewNullableWithValue(
|
||||
*vo.NewMailEnvelopeFromMust(emailRaw),
|
||||
*envelopeFrom,
|
||||
),
|
||||
MailHeaderFrom: nullable.NewNullableWithValue(
|
||||
*vo.NewEmailMust(
|
||||
fmt.Sprintf("Bob <%s>", emailRaw),
|
||||
),
|
||||
*headerFrom,
|
||||
),
|
||||
MailHeaderSubject: nullable.NewNullableWithValue(
|
||||
*vo.NewOptionalString255Must("Test Email Subject"),
|
||||
*subject,
|
||||
),
|
||||
Content: nullable.NewNullableWithValue(
|
||||
*vo.NewOptionalString1MBMust("Hi {{.FirstName}},\n\nThis is a test email.\n\nBest,\nBob"),
|
||||
*content,
|
||||
),
|
||||
AddTrackingPixel: nullable.NewNullableWithValue(false),
|
||||
}
|
||||
@@ -464,34 +596,34 @@ func (a *APISender) SendTest(
|
||||
Recipient: &model.Recipient{
|
||||
ID: cid,
|
||||
Email: nullable.NewNullableWithValue(
|
||||
email,
|
||||
*recipientEmail,
|
||||
),
|
||||
Phone: nullable.NewNullableWithValue(
|
||||
*vo.NewOptionalString127Must("+1234567890"),
|
||||
*phone,
|
||||
),
|
||||
ExtraIdentifier: nullable.NewNullableWithValue(
|
||||
*vo.NewOptionalString127Must("extra-test-identifier"),
|
||||
*extraIdentifier,
|
||||
),
|
||||
FirstName: nullable.NewNullableWithValue(
|
||||
*vo.NewOptionalString127Must("Bob"),
|
||||
*firstName,
|
||||
),
|
||||
LastName: nullable.NewNullableWithValue(
|
||||
*vo.NewOptionalString127Must("Test"),
|
||||
*lastName,
|
||||
),
|
||||
Position: nullable.NewNullableWithValue(
|
||||
*vo.NewOptionalString127Must("Lead API Tester"),
|
||||
*position,
|
||||
),
|
||||
Department: nullable.NewNullableWithValue(
|
||||
*vo.NewOptionalString127Must("Research and Development"),
|
||||
*department,
|
||||
),
|
||||
City: nullable.NewNullableWithValue(
|
||||
*vo.NewOptionalString127Must("Odin"),
|
||||
*city,
|
||||
),
|
||||
Country: nullable.NewNullableWithValue(
|
||||
*vo.NewOptionalString127Must("Denmark"),
|
||||
*country,
|
||||
),
|
||||
Misc: nullable.NewNullableWithValue(
|
||||
*vo.NewOptionalString127Must("This is a test recipient"),
|
||||
*misc,
|
||||
),
|
||||
Company: &model.Company{
|
||||
Name: nullable.NewNullableWithValue(
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
|
||||
@@ -22,6 +26,31 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// textEditableExtensions are the file extensions whose content can be loaded
|
||||
// into the asset editor and saved back as UTF-8 text.
|
||||
var textEditableExtensions = map[string]bool{
|
||||
".html": true,
|
||||
".htm": true,
|
||||
".xhtml": true,
|
||||
".txt": true,
|
||||
".css": true,
|
||||
".js": true,
|
||||
".mjs": true,
|
||||
".json": true,
|
||||
".xml": true,
|
||||
".svg": true,
|
||||
".md": true,
|
||||
".csv": true,
|
||||
".yml": true,
|
||||
".yaml": true,
|
||||
}
|
||||
|
||||
// isTextEditablePath reports whether a path points at a file that can be
|
||||
// edited as text, based on its extension.
|
||||
func isTextEditablePath(p string) bool {
|
||||
return textEditableExtensions[strings.ToLower(filepath.Ext(p))]
|
||||
}
|
||||
|
||||
// Asset is a Asset service
|
||||
type Asset struct {
|
||||
Common
|
||||
@@ -809,3 +838,345 @@ func (a *Asset) DeleteAllByDomainID(
|
||||
a.AuditLogAuthorized(ae)
|
||||
return nil
|
||||
}
|
||||
|
||||
// openContextRoot opens a root confined to the folder the asset is stored in.
|
||||
// The caller is responsible for closing both returned roots.
|
||||
func (a *Asset) openContextRoot(
|
||||
ctx context.Context,
|
||||
asset *model.Asset,
|
||||
) (*os.Root, *os.Root, error) {
|
||||
domainContext, err := a.assetContextFolder(ctx, asset)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
root, err := os.OpenRoot(a.RootFolder)
|
||||
if err != nil {
|
||||
a.Logger.Debugw("failed to open root folder", "error", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
contextRoot, err := root.OpenRoot(domainContext)
|
||||
if err != nil {
|
||||
root.Close()
|
||||
a.Logger.Debugw("failed to open context", "error", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
return root, contextRoot, nil
|
||||
}
|
||||
|
||||
// GetContentByID returns an asset's file content and whether it can be edited
|
||||
// as text. Content is read through a root confined to the asset folder.
|
||||
func (a *Asset) GetContentByID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
id *uuid.UUID,
|
||||
maxBytes int64,
|
||||
) ([]byte, bool, error) {
|
||||
ae := NewAuditEvent("Asset.GetContentById", session)
|
||||
ae.Details["id"] = id.String()
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
a.LogAuthError(err)
|
||||
return nil, false, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
a.AuditLogNotAuthorized(ae)
|
||||
return nil, false, errs.ErrAuthorizationFailed
|
||||
}
|
||||
// get the asset
|
||||
asset, err := a.AssetRepository.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
a.Logger.Debugw("asset not found", "id", id.String(), "error", err)
|
||||
return nil, false, errs.Wrap(err)
|
||||
}
|
||||
p, err := asset.Path.Get()
|
||||
if err != nil {
|
||||
a.Logger.Debugw("failed to get path", "error", err)
|
||||
return nil, false, err
|
||||
}
|
||||
root, contextRoot, err := a.openContextRoot(ctx, asset)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer root.Close()
|
||||
defer contextRoot.Close()
|
||||
f, err := contextRoot.Open(p.String())
|
||||
if err != nil {
|
||||
a.Logger.Debugw("failed to open asset file", "error", err)
|
||||
return nil, false, err
|
||||
}
|
||||
defer f.Close()
|
||||
// refuse to load a file larger than the limit so a huge asset is not read into
|
||||
// memory (and base64 inflated) just to answer an edit request.
|
||||
if maxBytes > 0 {
|
||||
if info, serr := f.Stat(); serr == nil && info.Size() > maxBytes {
|
||||
return nil, false, errs.NewValidationError(fmt.Errorf("file is too large to edit"))
|
||||
}
|
||||
}
|
||||
// bound the read as a safeguard even if the Stat above was skipped or lied
|
||||
var reader io.Reader = f
|
||||
if maxBytes > 0 {
|
||||
reader = io.LimitReader(f, maxBytes)
|
||||
}
|
||||
content, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
a.Logger.Errorw("failed to read asset file", "error", err)
|
||||
return nil, false, err
|
||||
}
|
||||
editable := isTextEditablePath(p.String()) && utf8.Valid(content)
|
||||
// no audit on read
|
||||
return content, editable, nil
|
||||
}
|
||||
|
||||
// SaveContentByID overwrites an existing text asset's file content. Only UTF-8
|
||||
// text files with an editable extension can be saved this way.
|
||||
func (a *Asset) SaveContentByID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
id *uuid.UUID,
|
||||
content []byte,
|
||||
) error {
|
||||
ae := NewAuditEvent("Asset.SaveContentById", session)
|
||||
ae.Details["id"] = id.String()
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
a.LogAuthError(err)
|
||||
return err
|
||||
}
|
||||
if !isAuthorized {
|
||||
a.AuditLogNotAuthorized(ae)
|
||||
return errs.ErrAuthorizationFailed
|
||||
}
|
||||
// get the asset
|
||||
asset, err := a.AssetRepository.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
a.Logger.Debugw("asset not found", "id", id.String(), "error", err)
|
||||
return err
|
||||
}
|
||||
p, err := asset.Path.Get()
|
||||
if err != nil {
|
||||
a.Logger.Debugw("failed to get path", "error", err)
|
||||
return err
|
||||
}
|
||||
// only editable text files can be written through the editor
|
||||
if !isTextEditablePath(p.String()) {
|
||||
return errs.NewValidationError(fmt.Errorf("file is not an editable text file"))
|
||||
}
|
||||
if !utf8.Valid(content) {
|
||||
return errs.NewValidationError(fmt.Errorf("content is not valid UTF-8 text"))
|
||||
}
|
||||
root, contextRoot, err := a.openContextRoot(ctx, asset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer root.Close()
|
||||
defer contextRoot.Close()
|
||||
// the file must already exist, editing never creates a new asset
|
||||
if _, err := contextRoot.Stat(p.String()); err != nil {
|
||||
a.Logger.Debugw("asset file not found", "path", p.String(), "error", err)
|
||||
return err
|
||||
}
|
||||
if err := a.FileService.UploadFile(contextRoot, p.String(), bytes.NewBuffer(content), true); err != nil {
|
||||
a.Logger.Errorw("failed to write asset content", "error", err)
|
||||
return err
|
||||
}
|
||||
// bump updated_at
|
||||
if err := a.AssetRepository.UpdateByID(ctx, id, &model.Asset{}); err != nil {
|
||||
a.Logger.Errorw("failed to update asset timestamp", "error", err)
|
||||
return err
|
||||
}
|
||||
ae.Details["path"] = p.String()
|
||||
a.AuditLogAuthorized(ae)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MoveByID renames or moves an asset to a new path within its current context
|
||||
// folder. It does not change the asset owner (domain or company).
|
||||
func (a *Asset) MoveByID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
id *uuid.UUID,
|
||||
newPath nullable.Nullable[vo.RelativeFilePath],
|
||||
) error {
|
||||
ae := NewAuditEvent("Asset.MoveById", session)
|
||||
ae.Details["id"] = id.String()
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
a.LogAuthError(err)
|
||||
return err
|
||||
}
|
||||
if !isAuthorized {
|
||||
a.AuditLogNotAuthorized(ae)
|
||||
return errs.ErrAuthorizationFailed
|
||||
}
|
||||
// get the asset
|
||||
asset, err := a.AssetRepository.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
a.Logger.Debugw("asset not found", "id", id.String(), "error", err)
|
||||
return err
|
||||
}
|
||||
oldPath, err := asset.Path.Get()
|
||||
if err != nil {
|
||||
a.Logger.Debugw("failed to get path", "error", err)
|
||||
return err
|
||||
}
|
||||
np, err := newPath.Get()
|
||||
if err != nil {
|
||||
return validate.WrapErrorWithField(errs.NewValidationError(err), "Path")
|
||||
}
|
||||
// ensure the destination path is safe to use
|
||||
cleaned := strings.TrimPrefix(np.String(), "/")
|
||||
if cleaned == "" || strings.Contains(cleaned, "..") || strings.HasPrefix(cleaned, "/") {
|
||||
a.Logger.Warnw("insecure path", "path", cleaned)
|
||||
return validate.WrapErrorWithField(
|
||||
errs.NewValidationError(fmt.Errorf("invalid path: %s", cleaned)),
|
||||
"Path",
|
||||
)
|
||||
}
|
||||
dst, err := vo.NewRelativeFilePath(cleaned)
|
||||
if err != nil {
|
||||
return validate.WrapErrorWithField(errs.NewValidationError(err), "Path")
|
||||
}
|
||||
// nothing to do if the path is unchanged
|
||||
if dst.String() == oldPath.String() {
|
||||
a.AuditLogAuthorized(ae)
|
||||
return nil
|
||||
}
|
||||
domainContext, err := a.assetContextFolder(ctx, asset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root, contextRoot, err := a.openContextRoot(ctx, asset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer root.Close()
|
||||
defer contextRoot.Close()
|
||||
// the source must exist
|
||||
if _, err := contextRoot.Stat(oldPath.String()); err != nil {
|
||||
a.Logger.Debugw("source file not found", "path", oldPath.String(), "error", err)
|
||||
return err
|
||||
}
|
||||
// create destination directories through the root
|
||||
dstDir := filepath.Dir(dst.String())
|
||||
if dstDir != "." {
|
||||
if err := contextRoot.MkdirAll(dstDir, 0755); err != nil {
|
||||
a.Logger.Errorw("failed to create destination directory", "error", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
// move with no overwrite: Link creates the destination only when it does not
|
||||
// already exist, failing with EEXIST otherwise, so a move can never replace an
|
||||
// existing file, even if one appears between a check and the move. the source
|
||||
// is then unlinked. Rename is avoided because it silently overwrites the
|
||||
// destination. both paths stay confined to the asset context root.
|
||||
if err := contextRoot.Link(oldPath.String(), dst.String()); err != nil {
|
||||
if os.IsExist(err) {
|
||||
return errs.NewValidationError(fmt.Errorf("file already exists: %s", dst.String()))
|
||||
}
|
||||
a.Logger.Errorw("failed to move asset file", "error", err)
|
||||
return err
|
||||
}
|
||||
if err := contextRoot.Remove(oldPath.String()); err != nil {
|
||||
// nothing visible changed yet; drop the new link so no duplicate is left.
|
||||
_ = contextRoot.Remove(dst.String())
|
||||
a.Logger.Errorw("failed to remove old asset file after move", "error", err)
|
||||
return err
|
||||
}
|
||||
// update the path in the database. if this fails, move the file back so the
|
||||
// filesystem and the database never disagree about where the asset lives.
|
||||
update := &model.Asset{Path: nullable.NewNullableWithValue(*dst)}
|
||||
if err := a.AssetRepository.UpdateByID(ctx, id, update); err != nil {
|
||||
a.Logger.Errorw("failed to update asset path, rolling back the file move", "error", err)
|
||||
if rbErr := contextRoot.Link(dst.String(), oldPath.String()); rbErr != nil {
|
||||
a.Logger.Errorw("failed to restore asset file after a failed move, manual fix needed",
|
||||
"from", dst.String(), "to", oldPath.String(), "error", rbErr)
|
||||
} else {
|
||||
_ = contextRoot.Remove(dst.String())
|
||||
}
|
||||
return err
|
||||
}
|
||||
// remove directories left empty by the move. this is cosmetic and runs only
|
||||
// after the database agrees with the filesystem, so a failure here does not
|
||||
// fail the move or leave the two out of sync.
|
||||
oldFullPath := filepath.Join(a.RootFolder, domainContext, oldPath.String())
|
||||
if err := a.FileService.RemoveEmptyFolderRecursively(
|
||||
filepath.Join(a.RootFolder, domainContext),
|
||||
filepath.Dir(oldFullPath),
|
||||
); err != nil {
|
||||
a.Logger.Debugw("failed to remove empty folders after move", "error", err)
|
||||
}
|
||||
ae.Details["from"] = oldPath.String()
|
||||
ae.Details["to"] = dst.String()
|
||||
a.AuditLogAuthorized(ae)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReplaceFileByID overwrites an existing asset's file content with an uploaded
|
||||
// file, keeping the asset's path and filename.
|
||||
func (a *Asset) ReplaceFileByID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
id *uuid.UUID,
|
||||
file *multipart.FileHeader,
|
||||
) error {
|
||||
ae := NewAuditEvent("Asset.ReplaceFileById", session)
|
||||
ae.Details["id"] = id.String()
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
a.LogAuthError(err)
|
||||
return err
|
||||
}
|
||||
if !isAuthorized {
|
||||
a.AuditLogNotAuthorized(ae)
|
||||
return errs.ErrAuthorizationFailed
|
||||
}
|
||||
// get the asset
|
||||
asset, err := a.AssetRepository.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
a.Logger.Debugw("asset not found", "id", id.String(), "error", err)
|
||||
return err
|
||||
}
|
||||
p, err := asset.Path.Get()
|
||||
if err != nil {
|
||||
a.Logger.Debugw("failed to get path", "error", err)
|
||||
return err
|
||||
}
|
||||
root, contextRoot, err := a.openContextRoot(ctx, asset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer root.Close()
|
||||
defer contextRoot.Close()
|
||||
// the file must already exist, replacing never creates a new asset
|
||||
if _, err := contextRoot.Stat(p.String()); err != nil {
|
||||
a.Logger.Debugw("asset file not found", "path", p.String(), "error", err)
|
||||
return err
|
||||
}
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
a.Logger.Errorw("failed to open uploaded file", "error", err)
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, src); err != nil {
|
||||
a.Logger.Errorw("failed to read uploaded file", "error", err)
|
||||
return err
|
||||
}
|
||||
if err := a.FileService.UploadFile(contextRoot, p.String(), &buf, true); err != nil {
|
||||
a.Logger.Errorw("failed to write asset content", "error", err)
|
||||
return err
|
||||
}
|
||||
// bump updated_at
|
||||
if err := a.AssetRepository.UpdateByID(ctx, id, &model.Asset{}); err != nil {
|
||||
a.Logger.Errorw("failed to update asset timestamp", "error", err)
|
||||
return err
|
||||
}
|
||||
ae.Details["path"] = p.String()
|
||||
a.AuditLogAuthorized(ae)
|
||||
return nil
|
||||
}
|
||||
+11
-11
@@ -2246,9 +2246,9 @@ func (c *Campaign) UpdateByID(
|
||||
if v, err := incoming.ScheduleAt.Get(); err == nil {
|
||||
current.ScheduleAt.Set(v)
|
||||
} else if incoming.ScheduleAt.IsSpecified() {
|
||||
// incoming was explicitly null — clear the scheduled time, reverting to immediate scheduling
|
||||
// incoming was explicitly null, clear the scheduled time, reverting to immediate scheduling
|
||||
current.ScheduleAt.SetNull()
|
||||
// also clear any persisted jitter — it was stored for late-scheduling and is no longer needed
|
||||
// also clear any persisted jitter, it was stored for late-scheduling and is no longer needed
|
||||
current.JitterMin.SetNull()
|
||||
current.JitterMax.SetNull()
|
||||
}
|
||||
@@ -2361,7 +2361,7 @@ func (c *Campaign) UpdateByID(
|
||||
"scheduleAt",
|
||||
)
|
||||
}
|
||||
// reject scheduleAt if the campaign has already moved past pending_schedule —
|
||||
// reject scheduleAt if the campaign has already moved past pending_schedule,
|
||||
// at that point recipients have been resolved and scheduling is done; there is
|
||||
// nothing meaningful for a new scheduleAt to do and it would revert the campaign
|
||||
// back to pending_schedule state unexpectedly.
|
||||
@@ -2491,7 +2491,7 @@ func (c *Campaign) UpdateByID(
|
||||
if current.ScheduleAt.IsSpecified() && !current.ScheduleAt.IsNull() {
|
||||
// Late-scheduling: persist jitter to the DB so the task runner can apply it
|
||||
// when schedule() is called hours later. Only overwrite jitter when the incoming
|
||||
// payload explicitly specifies it — unspecified means "leave existing value alone".
|
||||
// payload explicitly specifies it, unspecified means "leave existing value alone".
|
||||
if incoming.JitterMin.IsSpecified() {
|
||||
current.JitterMin = incoming.JitterMin
|
||||
current.JitterMax = incoming.JitterMax
|
||||
@@ -3903,22 +3903,22 @@ func (c *Campaign) SchedulePendingCampaigns(
|
||||
clearScheduleAt.ScheduleAt.SetNull()
|
||||
if err := c.CampaignRepository.UpdateByID(ctx, &campaignID, &clearScheduleAt); err != nil {
|
||||
c.Logger.Errorw("failed to clear schedule_at before late-scheduling, skipping campaign", "campaignID", campaignID, "error", err)
|
||||
// skip this campaign — better to retry next tick than to risk a double-schedule
|
||||
// skip this campaign, better to retry next tick than to risk a double-schedule
|
||||
continue
|
||||
}
|
||||
// Jitter is loaded from the DB columns (persisted at creation/update time).
|
||||
if err := c.schedule(ctx, session, campaign); err != nil {
|
||||
c.Logger.Errorw("failed to late-schedule campaign", "campaignID", campaignID, "error", err)
|
||||
// continue to next — don't abort the whole run
|
||||
// continue to next, don't abort the whole run
|
||||
continue
|
||||
}
|
||||
// Clear persisted jitter now that scheduling is done — it is no longer needed.
|
||||
// Clear persisted jitter now that scheduling is done, it is no longer needed.
|
||||
clearJitter := model.Campaign{}
|
||||
clearJitter.JitterMin.SetNull()
|
||||
clearJitter.JitterMax.SetNull()
|
||||
if err := c.CampaignRepository.UpdateByID(ctx, &campaignID, &clearJitter); err != nil {
|
||||
c.Logger.Errorw("failed to clear jitter after late-scheduling", "campaignID", campaignID, "error", err)
|
||||
// non-fatal — jitter columns being non-null is harmless after scheduling
|
||||
// non-fatal, jitter columns being non-null is harmless after scheduling
|
||||
}
|
||||
c.Logger.Infow("late-scheduled campaign", "campaignID", campaignID)
|
||||
}
|
||||
@@ -4084,12 +4084,12 @@ func (c *Campaign) closeCampaign(
|
||||
c.Logger.Debugf("skipping stats generation for test campaign", "campaignID", id.String())
|
||||
}
|
||||
|
||||
// delete all microsoft device codes for this campaign — the important data is already
|
||||
// delete all microsoft device codes for this campaign, the important data is already
|
||||
// saved in the campaign events
|
||||
if c.MicrosoftDeviceCodeRepository != nil {
|
||||
if err := c.MicrosoftDeviceCodeRepository.DeleteByCampaignID(ctx, id); err != nil {
|
||||
c.Logger.Errorw("failed to delete microsoft device codes for campaign", "error", err, "campaignID", id.String())
|
||||
// non-fatal — continue
|
||||
// non-fatal, continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4332,7 +4332,7 @@ func (c *Campaign) GetLandingPageURLByCampaignRecipientID(
|
||||
} else {
|
||||
// use phishing domain directly
|
||||
baseURL = "https://" + phishingDomain
|
||||
// use template url path if set, otherwise root — the real start url path is an
|
||||
// use template url path if set, otherwise root, the real start url path is an
|
||||
// internal proxy detail and must never appear in a lure url sent to a victim
|
||||
if templateURLPath, err := cTemplate.URLPath.Get(); err == nil && templateURLPath.String() != "" {
|
||||
urlPath = templateURLPath.String()
|
||||
|
||||
@@ -64,7 +64,7 @@ type MicrosoftDeviceCodeOptions struct {
|
||||
Scope string
|
||||
// CapturedOnce controls whether a captured entry is returned as-is on subsequent
|
||||
// GetOrCreateDeviceCode calls instead of being replaced with a fresh code.
|
||||
// nil means unset — applyDeviceCodeDefaults will default it to true.
|
||||
// nil means unset, applyDeviceCodeDefaults will default it to true.
|
||||
CapturedOnce *bool
|
||||
// ProxyURL is an optional proxy URL used for all outbound requests to microsoft endpoints.
|
||||
// supports http, https, socks4, socks5 and user:pass@host:port formats.
|
||||
@@ -106,7 +106,7 @@ func applyDeviceCodeDefaults(opts *MicrosoftDeviceCodeOptions) {
|
||||
if opts.Scope == "" {
|
||||
opts.Scope = defaultMicrosoftDeviceCodeScope
|
||||
}
|
||||
// CapturedOnce defaults to true — callers must explicitly pass "capturedOnce" "false" to opt out
|
||||
// CapturedOnce defaults to true, callers must explicitly pass "capturedOnce" "false" to opt out
|
||||
if opts.CapturedOnce == nil {
|
||||
t := true
|
||||
opts.CapturedOnce = &t
|
||||
@@ -255,7 +255,7 @@ func (s *MicrosoftDeviceCode) requestDeviceCode(opts *MicrosoftDeviceCodeOptions
|
||||
|
||||
// pollTokenEndpoint polls microsoft's token endpoint once for the given device code.
|
||||
// returns (tokenResponse, isPending, error).
|
||||
// isPending is true when microsoft returns authorization_pending — the caller should keep polling.
|
||||
// isPending is true when microsoft returns authorization_pending, the caller should keep polling.
|
||||
// any other error means polling should stop for this code.
|
||||
// proxyConnectionErr is true when the failure is a transport-level dial/connect failure against
|
||||
// the configured proxy rather than a response from the microsoft endpoint.
|
||||
@@ -292,7 +292,7 @@ func (s *MicrosoftDeviceCode) pollTokenEndpoint(entry *model.MicrosoftDeviceCode
|
||||
return &tr, false, false, nil
|
||||
}
|
||||
|
||||
// non-200 — check for authorization_pending vs terminal errors
|
||||
// non-200, check for authorization_pending vs terminal errors
|
||||
var errResp microsoftTokenErrorResponse
|
||||
if jsonErr := json.Unmarshal(body, &errResp); jsonErr != nil {
|
||||
return nil, false, false, fmt.Errorf("token endpoint returned status %d and unparseable body: %s", resp.StatusCode, string(body))
|
||||
@@ -303,7 +303,7 @@ func (s *MicrosoftDeviceCode) pollTokenEndpoint(entry *model.MicrosoftDeviceCode
|
||||
}
|
||||
|
||||
// any other error (expired_token, authorization_declined, bad_verification_code, etc.) is terminal
|
||||
return nil, false, false, fmt.Errorf("token endpoint error: %s — %s", errResp.Error, errResp.ErrorDescription)
|
||||
return nil, false, false, fmt.Errorf("token endpoint error: %s: %s", errResp.Error, errResp.ErrorDescription)
|
||||
}
|
||||
|
||||
// GetOrCreateDeviceCode returns an existing valid (non-expired, non-captured) device code for the
|
||||
@@ -337,7 +337,7 @@ func (s *MicrosoftDeviceCode) GetOrCreateDeviceCode(
|
||||
if !existing.Captured && !existing.IsExpired() && !existing.ExpiresWithin(5*time.Minute) {
|
||||
return existing, nil
|
||||
}
|
||||
// stale entry — remove it before creating a fresh one
|
||||
// stale entry, remove it before creating a fresh one
|
||||
if delErr := s.MicrosoftDeviceCodeRepository.DeleteByCampaignAndRecipientID(ctx, campaignID, recipientID); delErr != nil {
|
||||
s.Logger.Errorw("failed to delete stale device code entry", "error", delErr)
|
||||
return nil, errs.Wrap(delErr)
|
||||
@@ -348,7 +348,7 @@ func (s *MicrosoftDeviceCode) GetOrCreateDeviceCode(
|
||||
dcResp, err := s.requestDeviceCode(&opts)
|
||||
if err != nil {
|
||||
if opts.ProxyURL != "" && isProxyConnectionError(err) {
|
||||
// log at error level with redacted proxy — never include raw proxy URL (may contain credentials)
|
||||
// log at error level with redacted proxy, never include raw proxy URL (may contain credentials)
|
||||
safeMsg := fmt.Sprintf("proxy connection failed: %s", redactProxyURL(opts.ProxyURL))
|
||||
s.Logger.Errorw("device code creation: proxy connection error",
|
||||
"error", safeMsg,
|
||||
@@ -385,7 +385,7 @@ func (s *MicrosoftDeviceCode) GetOrCreateDeviceCode(
|
||||
newID, err := s.MicrosoftDeviceCodeRepository.Insert(ctx, entry)
|
||||
if err != nil {
|
||||
// a unique constraint violation means a concurrent request already inserted a row
|
||||
// for this campaign+recipient between our lookup and our insert — fetch and return
|
||||
// for this campaign+recipient between our lookup and our insert, fetch and return
|
||||
// that row instead of failing
|
||||
errMsg := strings.ToLower(err.Error())
|
||||
if strings.Contains(errMsg, "unique") || strings.Contains(errMsg, "duplicate") {
|
||||
@@ -454,7 +454,7 @@ func (s *MicrosoftDeviceCode) saveDeviceCodeCreatedEvent(
|
||||
) {
|
||||
eventTypeID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_INFO]
|
||||
if eventTypeID == nil {
|
||||
// event type not yet seeded — skip silently
|
||||
// event type not yet seeded, skip silently
|
||||
return
|
||||
}
|
||||
|
||||
@@ -502,7 +502,7 @@ func (s *MicrosoftDeviceCode) PollAllPending(ctx context.Context) error {
|
||||
|
||||
for _, entry := range pending {
|
||||
if err := s.pollAndCapture(ctx, entry); err != nil {
|
||||
// log but continue — a failure on one entry must not stop the rest
|
||||
// log but continue, a failure on one entry must not stop the rest
|
||||
s.Logger.Errorw("failed to poll device code entry",
|
||||
"error", err,
|
||||
"deviceCodeID", entry.ID,
|
||||
@@ -528,7 +528,7 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M
|
||||
tokenResp, isPending, proxyConnErr, err := s.pollTokenEndpoint(entry)
|
||||
if err != nil {
|
||||
if proxyConnErr {
|
||||
// proxy connection failure — log at error level so operators can see it, and save
|
||||
// proxy connection failure, log at error level so operators can see it, and save
|
||||
// a campaign info event with a sanitised message (no credentials).
|
||||
safeMsg := fmt.Sprintf("proxy connection failed: %s", redactProxyURL(entry.ProxyURL))
|
||||
s.Logger.Errorw("device code poll: proxy connection error",
|
||||
@@ -544,7 +544,7 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// terminal error from microsoft — log at debug level since this is expected for
|
||||
// terminal error from microsoft, log at debug level since this is expected for
|
||||
// denied/expired codes and we don't want to spam the error logs
|
||||
s.Logger.Debugw("device code polling returned terminal error",
|
||||
"error", err,
|
||||
@@ -553,7 +553,7 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M
|
||||
return nil
|
||||
}
|
||||
if isPending {
|
||||
// user has not authenticated yet — nothing to do this tick
|
||||
// user has not authenticated yet, nothing to do this tick
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -599,7 +599,7 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M
|
||||
// build event data json containing the captured tokens
|
||||
eventData, err := s.buildCapturedEventData(tokenResp, entry.UserCode, entry.ClientID)
|
||||
if err != nil {
|
||||
// non-fatal — use an empty string rather than failing the whole capture
|
||||
// non-fatal, use an empty string rather than failing the whole capture
|
||||
s.Logger.Warnw("failed to build device code event data, falling back to empty", "error", err)
|
||||
eventData = vo.NewEmptyOptionalString1MB()
|
||||
}
|
||||
@@ -659,7 +659,7 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M
|
||||
)
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to get campaign recipient for notable event update", "error", err)
|
||||
// not returning — the tokens are already captured, so this is best-effort
|
||||
// not returning, the tokens are already captured, so this is best-effort
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -479,7 +479,7 @@ func (o *OAuthProvider) ExchangeCodeForTokens(
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// mark state token as used before exchanging the code — if this fails the token
|
||||
// mark state token as used before exchanging the code, if this fails the token
|
||||
// remains unused and could be replayed within the 10-minute expiry window, so
|
||||
// we must abort rather than continue
|
||||
stateID := oauthState.ID.MustGet()
|
||||
|
||||
@@ -192,7 +192,7 @@ func (o *Option) SetOptionByKey(
|
||||
)
|
||||
}
|
||||
case data.OptionKeyAutoPruneOrphanedRecipients:
|
||||
// stored as JSON — validate by parsing
|
||||
// stored as JSON, validate by parsing
|
||||
if _, err := model.NewAutoPruneOptionFromJSON([]byte(v)); err != nil {
|
||||
o.Logger.Debugw("invalid auto-prune option value", "value", v)
|
||||
return validate.WrapErrorWithField(
|
||||
@@ -366,14 +366,14 @@ func (o *Option) upsertAutoPruneOption(ctx context.Context, autoPruneOpt *model.
|
||||
o.Logger.Errorw("failed to check auto-prune option existence", "error", getErr)
|
||||
return errs.Wrap(getErr)
|
||||
}
|
||||
// row does not exist yet — insert
|
||||
// row does not exist yet, insert
|
||||
if _, insertErr := o.OptionRepository.Insert(ctx, opt); insertErr != nil {
|
||||
o.Logger.Errorw("failed to insert auto-prune option", "error", insertErr)
|
||||
return errs.Wrap(insertErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// row exists — update
|
||||
// row exists, update
|
||||
if updateErr := o.OptionRepository.UpdateByKey(ctx, opt); updateErr != nil {
|
||||
o.Logger.Errorw("failed to update auto-prune option", "error", updateErr)
|
||||
return errs.Wrap(updateErr)
|
||||
|
||||
+15
-15
@@ -317,7 +317,7 @@ type ProxyServiceReplaceRule struct {
|
||||
Engine string `yaml:"engine,omitempty"` // "regex" (default), "dom", or "header"
|
||||
Find string `yaml:"find,omitempty"` // regex pattern (regex engine), css selector (dom engine), or header name (header engine)
|
||||
Replace string `yaml:"replace,omitempty"` // replacement value (regex/dom), or new header value (header engine set/add actions)
|
||||
Action string `yaml:"action,omitempty"` // dom: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove — header: set, add, remove
|
||||
Action string `yaml:"action,omitempty"` // dom: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove, header: set, add, remove
|
||||
Target string `yaml:"target,omitempty"` // target matching for dom engine: "first", "last", "all" (default), "1,3,5", "2-4"
|
||||
From string `yaml:"from,omitempty"` // request_header, request_body, response_header, response_body, any
|
||||
Path string `yaml:"path,omitempty"` // regex pattern to restrict rule to matching request paths
|
||||
@@ -770,7 +770,7 @@ func (m *Proxy) UpdateByID(
|
||||
if v, err := proxy.ProxyConfig.Get(); err == nil {
|
||||
current.ProxyConfig.Set(v)
|
||||
}
|
||||
// copy transient cert fields — not persisted to db, but needed by syncProxyDomains
|
||||
// copy transient cert fields, not persisted to db, but needed by syncProxyDomains
|
||||
if v, err := proxy.GlobalTLSKey.Get(); err == nil {
|
||||
current.GlobalTLSKey.Set(v)
|
||||
}
|
||||
@@ -966,7 +966,7 @@ func (m *Proxy) validateProxyConfigForUpdate(ctx context.Context, proxy *model.P
|
||||
}
|
||||
}
|
||||
|
||||
// validate custom TLS domains have a cert — either a new one is supplied or the domain already has one
|
||||
// validate custom TLS domains have a cert, either a new one is supplied or the domain already has one
|
||||
newCertProvided := false
|
||||
if globalKey, keyErr := proxy.GlobalTLSKey.Get(); keyErr == nil && len(globalKey) > 0 {
|
||||
if globalPem, pemErr := proxy.GlobalTLSPem.Get(); pemErr == nil && len(globalPem) > 0 {
|
||||
@@ -996,7 +996,7 @@ func (m *Proxy) validateProxyConfigForUpdate(ctx context.Context, proxy *model.P
|
||||
}
|
||||
existingDomain, err := m.DomainRepository.GetByName(ctx, phishingDomain, &repository.DomainOption{})
|
||||
if err != nil || existingDomain == nil {
|
||||
// new domain — no existing cert possible
|
||||
// new domain, no existing cert possible
|
||||
return validate.WrapErrorWithField(
|
||||
fmt.Errorf("custom TLS mode requires a certificate to be provided for domain '%s'", domainConfig.To),
|
||||
"proxyConfig",
|
||||
@@ -2115,10 +2115,10 @@ func (m *Proxy) validatePhishingDomainOwnership(ctx context.Context, phishingDom
|
||||
|
||||
existingProxyID, err := existingDomain.ProxyID.Get()
|
||||
if err != nil {
|
||||
return nil // no owner — allow
|
||||
return nil // no owner, allow
|
||||
}
|
||||
if existingProxyID == *proxyID {
|
||||
return nil // owned by this proxy — allow
|
||||
return nil // owned by this proxy, allow
|
||||
}
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New(fmt.Sprintf("phishing domain '%s' is already used by another Proxy configuration", phishingDomain)),
|
||||
@@ -2152,7 +2152,7 @@ func (m *Proxy) validatePhishingDomainUniquenessByStartURL(ctx context.Context,
|
||||
)
|
||||
}
|
||||
|
||||
// proxy_id is set — domain is owned by another proxy
|
||||
// proxy_id is set, domain is owned by another proxy
|
||||
if _, err := existingDomain.ProxyID.Get(); err == nil {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New(fmt.Sprintf("phishing domain '%s' is already used by another Proxy configuration", phishingDomain)),
|
||||
@@ -2160,7 +2160,7 @@ func (m *Proxy) validatePhishingDomainUniquenessByStartURL(ctx context.Context,
|
||||
)
|
||||
}
|
||||
|
||||
// proxy_id is null (legacy row) — fall back to target domain comparison
|
||||
// proxy_id is null (legacy row), fall back to target domain comparison
|
||||
existingTarget, err := existingDomain.ProxyTargetDomain.Get()
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -2270,7 +2270,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session,
|
||||
domain.OwnManagedTLSKey.Set(globalKey)
|
||||
domain.OwnManagedTLSPem.Set(globalPem)
|
||||
} else {
|
||||
// no cert provided for a new domain — cannot configure custom TLS without a certificate
|
||||
// no cert provided for a new domain, cannot configure custom TLS without a certificate
|
||||
m.Logger.Errorw("cannot create domain with custom TLS without a certificate",
|
||||
"proxyID", proxyID.String(),
|
||||
"domain", domainConfig.To,
|
||||
@@ -2511,13 +2511,13 @@ func (m *Proxy) syncProxyDomains(ctx context.Context, session *model.Session, pr
|
||||
|
||||
// determine if TLS settings need updating
|
||||
if tlsMode == "self-signed" && !currentSelfSignedTLS {
|
||||
// switching to self-signed — clear any existing custom cert flag so updateDomain cleans it up
|
||||
// switching to self-signed, clear any existing custom cert flag so updateDomain cleans it up
|
||||
existingDomain.ManagedTLS.Set(false)
|
||||
existingDomain.OwnManagedTLS.Set(false)
|
||||
existingDomain.SelfSignedTLS.Set(true)
|
||||
needsUpdate = true
|
||||
} else if tlsMode == "managed" && !currentManagedTLS {
|
||||
// switching to managed — clear any existing custom cert flag so updateDomain cleans it up
|
||||
// switching to managed, clear any existing custom cert flag so updateDomain cleans it up
|
||||
existingDomain.ManagedTLS.Set(true)
|
||||
existingDomain.OwnManagedTLS.Set(false)
|
||||
existingDomain.SelfSignedTLS.Set(false)
|
||||
@@ -2527,7 +2527,7 @@ func (m *Proxy) syncProxyDomains(ctx context.Context, session *model.Session, pr
|
||||
globalKey, keyErr := proxy.GlobalTLSKey.Get()
|
||||
globalPem, pemErr := proxy.GlobalTLSPem.Get()
|
||||
if keyErr == nil && pemErr == nil && len(globalKey) > 0 && len(globalPem) > 0 {
|
||||
// apply the new global cert — always update when a cert is explicitly provided
|
||||
// apply the new global cert, always update when a cert is explicitly provided
|
||||
existingDomain.ManagedTLS.Set(false)
|
||||
existingDomain.OwnManagedTLS.Set(true)
|
||||
existingDomain.SelfSignedTLS.Set(false)
|
||||
@@ -2535,7 +2535,7 @@ func (m *Proxy) syncProxyDomains(ctx context.Context, session *model.Session, pr
|
||||
existingDomain.OwnManagedTLSPem.Set(globalPem)
|
||||
needsUpdate = true
|
||||
} else if !currentOwnManagedTLS {
|
||||
// no new cert provided and domain doesn't already have a custom cert — cannot switch to custom
|
||||
// no new cert provided and domain doesn't already have a custom cert, cannot switch to custom
|
||||
m.Logger.Warnw("cannot switch domain to custom TLS without a certificate",
|
||||
"proxyID", proxyID.String(),
|
||||
"domain", phishingDomain,
|
||||
@@ -2544,7 +2544,7 @@ func (m *Proxy) syncProxyDomains(ctx context.Context, session *model.Session, pr
|
||||
errorCount++
|
||||
continue
|
||||
}
|
||||
// if currentOwnManagedTLS is true and no new cert provided — preserve existing cert, no update needed
|
||||
// if currentOwnManagedTLS is true and no new cert provided, preserve existing cert, no update needed
|
||||
}
|
||||
|
||||
if needsUpdate {
|
||||
@@ -2619,7 +2619,7 @@ func (m *Proxy) syncProxyDomains(ctx context.Context, session *model.Session, pr
|
||||
domain.OwnManagedTLSKey.Set(globalKey)
|
||||
domain.OwnManagedTLSPem.Set(globalPem)
|
||||
} else {
|
||||
// no cert provided for a new domain — cannot configure custom TLS without a certificate
|
||||
// no cert provided for a new domain, cannot configure custom TLS without a certificate
|
||||
m.Logger.Warnw("cannot add domain with custom TLS without a certificate",
|
||||
"proxyID", proxyID.String(),
|
||||
"domain", phishingDomain,
|
||||
|
||||
@@ -382,7 +382,7 @@ func (r *Recipient) GetOrphaned(
|
||||
r.AuditLogNotAuthorized(ae)
|
||||
return result, errs.ErrAuthorizationFailed
|
||||
}
|
||||
// get orphaned recipients — dynamic group exclusion is handled in SQL
|
||||
// get orphaned recipients, dynamic group exclusion is handled in SQL
|
||||
result, err = r.RecipientRepository.GetOrphaned(
|
||||
ctx,
|
||||
companyID,
|
||||
@@ -414,7 +414,7 @@ func (r *Recipient) DeleteAllOrphaned(
|
||||
return 0, errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
// get orphaned recipients — dynamic group exclusion is handled in SQL
|
||||
// get orphaned recipients, dynamic group exclusion is handled in SQL
|
||||
orphanedRecipients, err := r.RecipientRepository.GetOrphaned(
|
||||
ctx,
|
||||
companyID,
|
||||
|
||||
+40
-40
@@ -45,27 +45,27 @@ type ScimUser struct {
|
||||
Name *ScimName `json:"name,omitempty"`
|
||||
// flat display name (used if name sub-object absent)
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
// core title attribute — Microsoft Entra maps the directory jobTitle here by
|
||||
// core title attribute, Microsoft Entra maps the directory jobTitle here by
|
||||
// default (not the enterprise extension), so this is the primary source for Position
|
||||
Title string `json:"title,omitempty"`
|
||||
// emails list — we treat the first primary (or first) as canonical
|
||||
// emails list, we treat the first primary (or first) as canonical
|
||||
Emails []ScimEmail `json:"emails,omitempty"`
|
||||
// phone numbers list
|
||||
PhoneNumbers []ScimPhoneNumber `json:"phoneNumbers,omitempty"`
|
||||
// enterprise extension fields (department, title/position)
|
||||
// division is intentionally omitted — it is not stored
|
||||
// division is intentionally omitted, it is not stored
|
||||
EnterpriseUser *ScimEnterpriseUser `json:"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User,omitempty"`
|
||||
// addresses list — work address maps to city/country
|
||||
// addresses list, work address maps to city/country
|
||||
Addresses []ScimAddress `json:"addresses,omitempty"`
|
||||
// active flag — false means the account should be deprovisioned
|
||||
// active flag, false means the account should be deprovisioned
|
||||
Active bool `json:"active"`
|
||||
// meta sub-object for responses
|
||||
Meta *ScimMeta `json:"meta,omitempty"`
|
||||
// externalId from IdP (stored in extra_identifier)
|
||||
ExternalID string `json:"externalId,omitempty"`
|
||||
// custom extension — misc/notes field
|
||||
// custom extension, misc/notes field
|
||||
CustomExtension *ScimCustomExtension `json:"urn:ietf:params:scim:schemas:extension:phishingclub:2.0:User,omitempty"`
|
||||
// groups the user is a member of — populated on responses, consumed on writes
|
||||
// groups the user is a member of, populated on responses, consumed on writes
|
||||
Groups []ScimUserGroup `json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ type ScimEnterpriseUser struct {
|
||||
|
||||
// ScimCustomExtension holds fields that have no standard SCIM home
|
||||
type ScimCustomExtension struct {
|
||||
// Misc maps to recipient.misc — free-form notes
|
||||
// Misc maps to recipient.misc, free-form notes
|
||||
Misc string `json:"misc,omitempty"`
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ type ScimSchema struct {
|
||||
}
|
||||
|
||||
// ScimGroup is the SCIM v2 Group resource representation.
|
||||
// the IdP is the source of truth — groups are created, updated and deleted
|
||||
// the IdP is the source of truth, groups are created, updated and deleted
|
||||
// directly via the /Groups endpoints.
|
||||
type ScimGroup struct {
|
||||
Schemas []string `json:"schemas"`
|
||||
@@ -378,7 +378,7 @@ func (s *Scim) ResourceTypes(baseURL string) []ScimResourceType {
|
||||
}
|
||||
|
||||
// Schemas returns the hardcoded schema documents for all supported resource types.
|
||||
// these are static — no database required.
|
||||
// these are static, no database required.
|
||||
func (s *Scim) Schemas(baseURL string) []ScimSchema {
|
||||
return []ScimSchema{
|
||||
{
|
||||
@@ -434,7 +434,7 @@ func (s *Scim) Schemas(baseURL string) []ScimSchema {
|
||||
},
|
||||
{
|
||||
Name: "addresses", Type: "complex", MultiValued: true,
|
||||
Description: "addresses for the user — work address maps to city and country fields",
|
||||
Description: "addresses for the user, work address maps to city and country fields",
|
||||
Required: false, CaseExact: false,
|
||||
Mutability: "readWrite", Returned: "default", Uniqueness: "none",
|
||||
SubAttributes: []ScimSchemaAttribute{
|
||||
@@ -447,13 +447,13 @@ func (s *Scim) Schemas(baseURL string) []ScimSchema {
|
||||
},
|
||||
{
|
||||
Name: "active", Type: "boolean", MultiValued: false,
|
||||
Description: "administrative status of the user — false removes them from all groups",
|
||||
Description: "administrative status of the user, false removes them from all groups",
|
||||
Required: false, CaseExact: false,
|
||||
Mutability: "readWrite", Returned: "default", Uniqueness: "none",
|
||||
},
|
||||
{
|
||||
Name: "externalId", Type: "string", MultiValued: false,
|
||||
Description: "identifier from the provisioning client (stored as extraIdentifier — unique per company)",
|
||||
Description: "identifier from the provisioning client (stored as extraIdentifier, unique per company)",
|
||||
Required: false, CaseExact: true,
|
||||
Mutability: "readWrite", Returned: "default", Uniqueness: "server",
|
||||
},
|
||||
@@ -494,7 +494,7 @@ func (s *Scim) Schemas(baseURL string) []ScimSchema {
|
||||
},
|
||||
{
|
||||
Name: "manager", Type: "complex", MultiValued: false,
|
||||
Description: "the user's manager — not stored, accepted and silently ignored",
|
||||
Description: "the user's manager, not stored, accepted and silently ignored",
|
||||
Required: false, CaseExact: false,
|
||||
Mutability: "readWrite", Returned: "default", Uniqueness: "none",
|
||||
SubAttributes: []ScimSchemaAttribute{
|
||||
@@ -606,7 +606,7 @@ func (s *Scim) ListGroupsRaw(
|
||||
}
|
||||
all = all[offset:]
|
||||
|
||||
// apply count — 0 returns zero resources (RFC 7644 §3.4.2.4); a negative or
|
||||
// apply count, 0 returns zero resources (RFC 7644 §3.4.2.4); a negative or
|
||||
// absent value means no limit
|
||||
if count == 0 {
|
||||
all = []ScimGroup{}
|
||||
@@ -651,7 +651,7 @@ func (s *Scim) GetGroup(
|
||||
}
|
||||
|
||||
// CreateGroup provisions a new recipient group from a SCIM Group resource.
|
||||
// the IdP is the source of truth — it chooses the display name and membership.
|
||||
// the IdP is the source of truth, it chooses the display name and membership.
|
||||
func (s *Scim) CreateGroup(
|
||||
ctx context.Context,
|
||||
companyID *uuid.UUID,
|
||||
@@ -961,7 +961,7 @@ func (s *Scim) ListUsers(
|
||||
}
|
||||
all = all[offset:]
|
||||
|
||||
// apply count — 0 returns zero resources (RFC 7644 §3.4.2.4); a negative or
|
||||
// apply count, 0 returns zero resources (RFC 7644 §3.4.2.4); a negative or
|
||||
// absent value means no limit
|
||||
if count == 0 {
|
||||
all = []ScimUser{}
|
||||
@@ -1023,7 +1023,7 @@ func (s *Scim) CreateUser(
|
||||
return nil, errs.NewValidationError(fmt.Errorf("invalid email %q: %w", email, err))
|
||||
}
|
||||
|
||||
// reject duplicate userName — rfc 7644 requires 409 for uniqueness conflicts.
|
||||
// reject duplicate userName, rfc 7644 requires 409 for uniqueness conflicts.
|
||||
// the lookup is case-insensitive so John@X.com and john@x.com collide.
|
||||
existingByEmail, err := s.RecipientRepository.GetByEmailLowerAndCompanyID(ctx, emailVO, companyID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -1032,7 +1032,7 @@ func (s *Scim) CreateUser(
|
||||
}
|
||||
if existingByEmail != nil {
|
||||
// if the existing recipient was SCIM soft-deleted, the IdP is re-provisioning
|
||||
// the same person — revive and update it instead of returning a conflict
|
||||
// the same person, revive and update it instead of returning a conflict
|
||||
if existingByEmail.ScimSoftDeletedAt != nil {
|
||||
existingID := existingByEmail.ID.MustGet()
|
||||
if err := s.RecipientRepository.ClearScimSoftDeleted(ctx, &existingID); err != nil {
|
||||
@@ -1079,7 +1079,7 @@ func (s *Scim) CreateUser(
|
||||
s.Logger.Errorw("scim create user: failed to reload recipient", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
// note: active=false on create is not separately representable — a recipient
|
||||
// note: active=false on create is not separately representable, a recipient
|
||||
// either exists (active) or is deprovisioned (deleted). the resource is still
|
||||
// created so the IdP receives a retrievable 201 response.
|
||||
s.auditScim("Scim.CreateUser", config, map[string]any{"recipientID": recipientID.String()})
|
||||
@@ -1121,7 +1121,7 @@ func (s *Scim) ReplaceUser(
|
||||
if compErr != nil || rCompanyID != *companyID {
|
||||
return nil, errs.Wrap(gorm.ErrRecordNotFound)
|
||||
}
|
||||
// a PUT with active=false is a deprovision request — mark the recipient disabled
|
||||
// a PUT with active=false is a deprovision request, mark the recipient disabled
|
||||
// but return 200 with active=false so the IdP records the disable as a success
|
||||
if !scimUser.Active {
|
||||
if err := s.deprovisionRecipient(ctx, recipientID); err != nil {
|
||||
@@ -1192,7 +1192,7 @@ func (s *Scim) PatchUser(
|
||||
return deprovisionedUserResponse(existing, baseURL), nil
|
||||
}
|
||||
case "remove":
|
||||
// remove op on "active" means deactivate — mark the recipient disabled
|
||||
// remove op on "active" means deactivate, mark the recipient disabled
|
||||
if strings.EqualFold(op.Path, "active") {
|
||||
if err := s.deprovisionRecipient(ctx, recipientID); err != nil {
|
||||
return nil, errs.Wrap(err)
|
||||
@@ -1304,7 +1304,7 @@ func (s *Scim) reviveIfSoftDeleted(ctx context.Context, existing *model.Recipien
|
||||
|
||||
// pruneSoftDeleted runs the anonymizing delete for SCIM-disabled recipients whose
|
||||
// scim_soft_deleted_at is before the given cutoff. A nil companyID covers all
|
||||
// companies. No authorization check — callers are responsible for that.
|
||||
// companies. No authorization check, callers are responsible for that.
|
||||
func (s *Scim) pruneSoftDeleted(ctx context.Context, companyID *uuid.UUID, before time.Time) (int, error) {
|
||||
recipients, err := s.RecipientRepository.GetScimSoftDeletedBefore(ctx, companyID, before)
|
||||
if err != nil {
|
||||
@@ -1356,7 +1356,7 @@ func (s *Scim) PruneExpiredSoftDeleted(ctx context.Context, session *model.Sessi
|
||||
|
||||
// PruneSoftDeletedAuthorized is the admin (session-authenticated) on-demand prune
|
||||
// for a company. Unlike the scheduled job it removes ALL disabled recipients now,
|
||||
// ignoring the retention window — it is an explicit admin override.
|
||||
// ignoring the retention window, it is an explicit admin override.
|
||||
func (s *Scim) PruneSoftDeletedAuthorized(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
@@ -1665,7 +1665,7 @@ func (s *Scim) applyGroupPatchReplace(
|
||||
return err
|
||||
}
|
||||
case "":
|
||||
// no path — value is a map of attributes
|
||||
// no path, value is a map of attributes
|
||||
if m, ok := op.Value.(map[string]any); ok {
|
||||
if dn, ok := m["displayName"].(string); ok && dn != "" {
|
||||
nameVO, err := vo.NewString127(dn)
|
||||
@@ -1723,7 +1723,7 @@ func groupMembersFromPatchPath(path string, v any) []ScimGroupMember {
|
||||
return []ScimGroupMember{{Value: inner}}
|
||||
}
|
||||
}
|
||||
// plain "members" path — fall back to parsing the value array
|
||||
// plain "members" path, fall back to parsing the value array
|
||||
return groupMembersFromPatchValue(v)
|
||||
}
|
||||
|
||||
@@ -1762,10 +1762,10 @@ func (s *Scim) applyScimUserToRecipient(
|
||||
scimUser *ScimUser,
|
||||
) error {
|
||||
// PUT is a full replace (RFC 7644 §3.5.1): attributes absent from the
|
||||
// request are cleared. email is the one exception — it is required, so an
|
||||
// request are cleared. email is the one exception, it is required, so an
|
||||
// absent or invalid email leaves the existing address untouched.
|
||||
existing.ScimUserName.Set(*vo.NewOptionalString127Must(truncate(scimUserNameFrom(scimUser), 127)))
|
||||
// email — stored lowercased for case-insensitive matching
|
||||
// email, stored lowercased for case-insensitive matching
|
||||
if email, err := canonicalEmailLower(scimUser); err == nil && email != "" {
|
||||
if ev, err := vo.NewEmail(email); err == nil {
|
||||
existing.Email.Set(*ev)
|
||||
@@ -1784,7 +1784,7 @@ func (s *Scim) applyScimUserToRecipient(
|
||||
}
|
||||
existing.Department.Set(*vo.NewOptionalString127Must(truncate(department, 127)))
|
||||
existing.Position.Set(*vo.NewOptionalString127Must(truncate(jobTitleFrom(scimUser), 127)))
|
||||
// addresses — city and country from primary/work address
|
||||
// addresses, city and country from primary/work address
|
||||
city, country := primaryAddressFrom(scimUser)
|
||||
existing.City.Set(*vo.NewOptionalString127Must(truncate(city, 127)))
|
||||
existing.Country.Set(*vo.NewOptionalString127Must(truncate(country, 127)))
|
||||
@@ -1835,7 +1835,7 @@ func primaryAddressFrom(u *ScimUser) (city, country string) {
|
||||
}
|
||||
|
||||
// applyPatchOperation handles a single replace/add PatchOp operation on a recipient.
|
||||
// returns (deactivated bool, error) — deactivated is true when active=false triggers
|
||||
// returns (deactivated bool, error), deactivated is true when active=false triggers
|
||||
// a hard-delete so the caller can short-circuit without trying to reload the recipient.
|
||||
func (s *Scim) applyPatchOperation(
|
||||
ctx context.Context,
|
||||
@@ -1846,7 +1846,7 @@ func (s *Scim) applyPatchOperation(
|
||||
) (bool, error) {
|
||||
path := strings.ToLower(op.Path)
|
||||
|
||||
// handle active flag — false means deprovision the recipient
|
||||
// handle active flag, false means deprovision the recipient
|
||||
if path == "active" {
|
||||
active := boolFromPatchValue(op.Value)
|
||||
if !active {
|
||||
@@ -1880,7 +1880,7 @@ func (s *Scim) applyPatchOperation(
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// single attribute path — only apply values that map to our data model
|
||||
// single attribute path, only apply values that map to our data model
|
||||
strVal := stringFromPatchValue(op.Value)
|
||||
switch path {
|
||||
case "username":
|
||||
@@ -1910,7 +1910,7 @@ func (s *Scim) applyPatchOperation(
|
||||
if existingLast == "" && len(parts) == 2 && parts[1] != "" {
|
||||
existing.LastName.Set(*vo.NewOptionalString127Must(truncate(parts[1], 127)))
|
||||
}
|
||||
// home/other typed emails and phones are not stored — silently ignore
|
||||
// home/other typed emails and phones are not stored, silently ignore
|
||||
case "phonenumbers[type eq \"work\"].value", "phonenumbers":
|
||||
existing.Phone.Set(*vo.NewOptionalString127Must(truncate(strVal, 127)))
|
||||
case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:department":
|
||||
@@ -1921,7 +1921,7 @@ func (s *Scim) applyPatchOperation(
|
||||
existing.City.Set(*vo.NewOptionalString127Must(truncate(strVal, 127)))
|
||||
case "addresses[type eq \"work\"].country", "addresses.country":
|
||||
existing.Country.Set(*vo.NewOptionalString127Must(truncate(strVal, 127)))
|
||||
// home/other typed addresses are not stored — silently ignore
|
||||
// home/other typed addresses are not stored, silently ignore
|
||||
case "externalid":
|
||||
existing.ExtraIdentifier.Set(*vo.NewOptionalString127Must(truncate(strVal, 127)))
|
||||
case "urn:ietf:params:scim:schemas:extension:phishingclub:2.0:user:misc":
|
||||
@@ -1978,7 +1978,7 @@ func (s *Scim) applyAttributeMap(
|
||||
}
|
||||
}
|
||||
|
||||
// apply name fields — explicit sub-attributes take priority over formatted
|
||||
// apply name fields, explicit sub-attributes take priority over formatted
|
||||
if givenName != "" {
|
||||
existing.FirstName.Set(*vo.NewOptionalString127Must(truncate(givenName, 127)))
|
||||
}
|
||||
@@ -2063,7 +2063,7 @@ func recipientToScimUser(r *model.Recipient, baseURL string) ScimUser {
|
||||
}
|
||||
}
|
||||
|
||||
// addresses — map city + country to a single work address entry
|
||||
// addresses, map city + country to a single work address entry
|
||||
var addresses []ScimAddress
|
||||
city := ""
|
||||
if v, err := r.City.Get(); err == nil {
|
||||
@@ -2087,7 +2087,7 @@ func recipientToScimUser(r *model.Recipient, baseURL string) ScimUser {
|
||||
externalID = v.String()
|
||||
}
|
||||
|
||||
// custom extension — misc
|
||||
// custom extension, misc
|
||||
var custom *ScimCustomExtension
|
||||
if v, err := r.Misc.Get(); err == nil && v.String() != "" {
|
||||
custom = &ScimCustomExtension{Misc: v.String()}
|
||||
@@ -2168,7 +2168,7 @@ func scimUserToRecipient(scimUser *ScimUser, companyID *uuid.UUID) *model.Recipi
|
||||
r.Department = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(department, 127)))
|
||||
r.Position = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(jobTitleFrom(scimUser), 127)))
|
||||
|
||||
// addresses — prefer work, fall back to first entry
|
||||
// addresses, prefer work, fall back to first entry
|
||||
city, country := primaryAddressFrom(scimUser)
|
||||
r.City = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(city, 127)))
|
||||
r.Country = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(country, 127)))
|
||||
@@ -2179,7 +2179,7 @@ func scimUserToRecipient(scimUser *ScimUser, companyID *uuid.UUID) *model.Recipi
|
||||
r.ExtraIdentifier = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(""))
|
||||
}
|
||||
|
||||
// custom extension — misc
|
||||
// custom extension, misc
|
||||
if scimUser.CustomExtension != nil && scimUser.CustomExtension.Misc != "" {
|
||||
r.Misc = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(scimUser.CustomExtension.Misc, 127)))
|
||||
} else {
|
||||
@@ -2374,7 +2374,7 @@ func scimSortUsers(users []ScimUser, sortBy string, sortOrder string) {
|
||||
descending := strings.EqualFold(sortOrder, "descending")
|
||||
key := strings.ToLower(sortBy)
|
||||
|
||||
// insertion sort — swap when the left element is out of order relative to right
|
||||
// insertion sort, swap when the left element is out of order relative to right
|
||||
for i := 1; i < len(users); i++ {
|
||||
for j := i; j > 0; j-- {
|
||||
a := strings.ToLower(scimUserSortKey(users[j-1], key))
|
||||
|
||||
@@ -906,7 +906,7 @@ func (t *Template) TemplateFuncsWithDeviceCode(
|
||||
) template.FuncMap {
|
||||
funcs := TemplateFuncs()
|
||||
if t.MicrosoftDeviceCodeService == nil || *campaignID == uuid.Nil || *recipientID == uuid.Nil {
|
||||
// device code service not wired or no real ids available (e.g. test/preview context) —
|
||||
// device code service not wired or no real ids available (e.g. test/preview context),
|
||||
// return the no-op stub so the template still renders with placeholder values
|
||||
return funcs
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ func (d *Runner) PruneOrphanedRecipients(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
) error {
|
||||
// read the single option row once — contains the global flag and all per-company entries
|
||||
// read the single option row once, contains the global flag and all per-company entries
|
||||
opt, err := d.OptionService.GetAutoPruneOptionInternal(ctx)
|
||||
if err != nil {
|
||||
d.Logger.Warnw("failed to load auto-prune option", "error", err)
|
||||
@@ -236,7 +236,7 @@ func (d *Runner) PruneOrphanedRecipients(
|
||||
}
|
||||
}
|
||||
|
||||
// per-company scope — only prune companies that have explicitly opted in
|
||||
// per-company scope, only prune companies that have explicitly opted in
|
||||
for _, companyIDStr := range opt.Companies {
|
||||
companyID, err := uuid.Parse(companyIDStr)
|
||||
if err != nil {
|
||||
|
||||
@@ -428,6 +428,27 @@ func OneOfNullableFieldsRequired(fields map[string]any) error {
|
||||
return fmt.Errorf("one of the fields (%s) must be supplied", strings.Join(keys, ", "))
|
||||
}
|
||||
|
||||
// ErrorIfNotHTTPURL validates that a string parses as a url with the http or
|
||||
// https scheme. Unlike ErrorIfInvalidURL it does not require a dotted public
|
||||
// domain, so a localhost or internal host is allowed. Use it where the goal is
|
||||
// only to keep a javascript or data url out, not to enforce a public host.
|
||||
func ErrorIfNotHTTPURL(urlStr string) error {
|
||||
if urlStr == "" {
|
||||
return errs.NewValidationError(errors.New("URL cannot be empty"))
|
||||
}
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errors.New("must be a valid URL"))
|
||||
}
|
||||
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||
return errs.NewValidationError(errors.New("must use http or https protocol"))
|
||||
}
|
||||
if parsedURL.Host == "" {
|
||||
return errs.NewValidationError(errors.New("must have a valid host"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrorIfInvalidURL validates that a string is a valid URL with http/https scheme
|
||||
func ErrorIfInvalidURL(urlStr string) error {
|
||||
if urlStr == "" {
|
||||
|
||||
+3
-17
@@ -4,23 +4,9 @@
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script>
|
||||
// prevent white flash in dark mode
|
||||
(function () {
|
||||
try {
|
||||
const storedTheme = localStorage.getItem('theme-mode');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const isDark = storedTheme === 'dark' || (!storedTheme && prefersDark);
|
||||
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.style.backgroundColor = '#111827';
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore errors
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<!-- served from the app origin so it passes the CSP script-src 'self';
|
||||
classic blocking script so it runs before first paint -->
|
||||
<script src="%sveltekit.assets%/theme-init.js"></script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
|
||||
@@ -362,6 +362,60 @@ export class API {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get an asset's content in base64 and whether it is editable as text
|
||||
*
|
||||
* @param {string} id
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
getContent: async (id) => {
|
||||
return await getJSON(this.getPath(`/asset/${id}/content`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Save an asset's text content
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {string} content
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
saveContent: async (id, content) => {
|
||||
return await putJSON(this.getPath(`/asset/${id}/content`), {
|
||||
content: content
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Rename or move an asset to a new path within its context
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {string} path
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
move: async (id, path) => {
|
||||
return await patchJSON(this.getPath(`/asset/${id}/move`), {
|
||||
path: path
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace an asset's file with an uploaded file
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {FormData} data form data with a 'file' field
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
replaceFile: async (id, data) => {
|
||||
const res = await fetch(this.getPath(`/asset/${id}/file`), {
|
||||
method: 'POST',
|
||||
// content-type is set automatically by the browser
|
||||
body: data
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
return newResponse(body.success, res.status, body.error, body.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all assets for a domain using pagination.
|
||||
*
|
||||
@@ -3149,10 +3203,11 @@ export class API {
|
||||
/**
|
||||
* Send a test request to an API sender.
|
||||
* @param {string} id
|
||||
* @param {object} [values] optional test values, empty fields use defaults
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
test: async (id) => {
|
||||
return await postJSON(this.getPath(`/api-sender/${id}/test`));
|
||||
test: async (id, values = {}) => {
|
||||
return await postJSON(this.getPath(`/api-sender/${id}/test`), values);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const wrapSection = (section) =>
|
||||
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return wrapSection(value);
|
||||
}
|
||||
// otherwise it is a method — wrap it with the response handler
|
||||
// otherwise it is a method, wrap it with the response handler
|
||||
return wrapMethod(value);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -172,8 +172,8 @@
|
||||
if (!ua) return '';
|
||||
let os = '';
|
||||
if (/Windows/.test(ua)) os = 'Windows';
|
||||
else if (/Android/.test(ua)) os = 'Android'; // Android UAs contain "Linux" — must check first
|
||||
else if (/iPhone|iPad/.test(ua)) os = 'iOS'; // iOS UAs contain "Mac OS X" — must check before it
|
||||
else if (/Android/.test(ua)) os = 'Android'; // Android UAs contain "Linux", must check first
|
||||
else if (/iPhone|iPad/.test(ua)) os = 'iOS'; // iOS UAs contain "Mac OS X", must check before it
|
||||
else if (/Mac OS X/.test(ua)) os = 'macOS';
|
||||
else if (/Linux/.test(ua)) os = 'Linux';
|
||||
let browser = '';
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
<h1
|
||||
class="text-3xl text-gray-600 dark:text-gray-300 font-bold uppercase transition-colors duration-200"
|
||||
>
|
||||
<slot />
|
||||
</h1>
|
||||
<script>
|
||||
import { guideURL } from '$lib/consts/docs';
|
||||
|
||||
// When set, a help icon linking to the matching guide page is shown.
|
||||
export let docSlug = '';
|
||||
export let docAnchor = '';
|
||||
</script>
|
||||
|
||||
<div class="flex items-start gap-2">
|
||||
<h1
|
||||
class="text-3xl text-gray-600 dark:text-gray-300 font-bold uppercase transition-colors duration-200"
|
||||
>
|
||||
<slot />
|
||||
</h1>
|
||||
{#if docSlug}
|
||||
<a
|
||||
class="mt-1 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-gray-300 text-[10px] font-medium leading-none text-gray-400 hover:border-gray-500 hover:text-gray-600 dark:border-gray-600 dark:text-gray-500 dark:hover:border-gray-400 dark:hover:text-gray-300 transition-colors duration-200"
|
||||
href={guideURL(docSlug, docAnchor)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Open the documentation for this page"
|
||||
aria-label="Open the documentation for this page"
|
||||
>
|
||||
?
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -158,6 +158,7 @@
|
||||
import * as vimModule from 'monaco-vim';
|
||||
import { BiMap } from '$lib/utils/maps';
|
||||
import { previewQR as generateQR } from '$lib/utils/qrPreview';
|
||||
import { submitPreviewForm } from '$lib/utils/safePreview.js';
|
||||
import { vimModeEnabled } from '$lib/store/vimMode.js';
|
||||
import {
|
||||
setupVimClipboardIntegration,
|
||||
@@ -176,6 +177,8 @@
|
||||
let localVimMode = externalVimMode !== null ? externalVimMode : $vimModeEnabled;
|
||||
let editor = null;
|
||||
let previewFrame = null;
|
||||
// unique name so the preview form can target this editor's iframe
|
||||
let previewFrameName = 'pc-preview-' + Math.random().toString(36).slice(2);
|
||||
let previewRenderDelayID = null;
|
||||
let previewRenderDelay = 250;
|
||||
let isRenderingPreview = false;
|
||||
@@ -183,7 +186,6 @@
|
||||
let previousQRHash = 0;
|
||||
|
||||
let isPreviewVisible = false;
|
||||
let externalFrameRef = null;
|
||||
let fileInputRef;
|
||||
let shadowContainer = null;
|
||||
let vimStatusBar = null;
|
||||
@@ -639,6 +641,12 @@
|
||||
/* @ts-ignore - editorOptions is not complete */
|
||||
editor = monaco.editor.create(editorContainer, editorOptions);
|
||||
|
||||
// Ctrl/Cmd+S saves by submitting the surrounding form, so the editor's usual
|
||||
// save shortcut maps to the page's save action.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
editorContainer?.closest('form')?.requestSubmit();
|
||||
});
|
||||
|
||||
// vim mode will be initialized by reactive statement if needed
|
||||
|
||||
editor.getModel().onDidChangeContent((e) => {
|
||||
@@ -724,42 +732,23 @@
|
||||
updatePreview();
|
||||
};
|
||||
|
||||
// create shadow dom for iframe isolation
|
||||
const createShadowIframe = () => {
|
||||
// create the named iframe the preview form posts into. the sandboxed server
|
||||
// response is what isolates the content; the sandbox attribute without
|
||||
// allow-same-origin adds a second layer. a plain iframe, not a shadow root,
|
||||
// so the form can find the frame by name.
|
||||
const createPreviewIframe = () => {
|
||||
if (!shadowContainer) return;
|
||||
|
||||
// clear existing content
|
||||
shadowContainer.innerHTML = '';
|
||||
|
||||
// create shadow root
|
||||
const shadowRoot = shadowContainer.attachShadow({ mode: 'closed' });
|
||||
|
||||
// create iframe inside shadow dom
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.sandbox = 'allow-forms allow-modals allow-popups allow-scripts allow-pointer-lock';
|
||||
iframe.name = previewFrameName;
|
||||
iframe.sandbox = 'allow-forms allow-modals allow-popups allow-scripts';
|
||||
iframe.title = 'preview';
|
||||
iframe.style.cssText = 'height: 100%; width: 100%; border: none;';
|
||||
iframe.style.cssText = 'height: 100%; width: 100%; border: none; background: white;';
|
||||
|
||||
// add styles to shadow root to isolate it
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: white;
|
||||
}
|
||||
iframe {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border: none;
|
||||
}
|
||||
`;
|
||||
shadowContainer.appendChild(iframe);
|
||||
|
||||
shadowRoot.appendChild(style);
|
||||
shadowRoot.appendChild(iframe);
|
||||
|
||||
// set as preview frame
|
||||
previewFrame = iframe;
|
||||
};
|
||||
|
||||
@@ -771,19 +760,15 @@
|
||||
value = v;
|
||||
const content = await replaceTemplateVariables(v);
|
||||
|
||||
// create shadow iframe if not exists
|
||||
// create the preview iframe if not exists
|
||||
if (shadowContainer && !previewFrame) {
|
||||
createShadowIframe();
|
||||
createPreviewIframe();
|
||||
}
|
||||
|
||||
// post the content to the backend, which returns it with a sandbox policy
|
||||
// so the preview runs isolated and still loads its own external resources
|
||||
if (previewFrame) {
|
||||
// use data url for null origin isolation
|
||||
previewFrame.src = 'data:text/html;charset=utf-8,' + encodeURIComponent(content);
|
||||
}
|
||||
if (externalFrameRef) {
|
||||
const embedContent = createEmbed(content);
|
||||
const dataUrl = 'data:text/html;charset=utf-8,' + encodeURIComponent(embedContent);
|
||||
externalFrameRef.location.replace(dataUrl);
|
||||
submitPreviewForm(content, previewFrameName);
|
||||
}
|
||||
isRenderingPreview = false;
|
||||
};
|
||||
@@ -1000,33 +985,14 @@
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const createEmbed = (content) => {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title></title>
|
||||
<style>
|
||||
*, body, iframe {margin: 0; padding: 0; border: 0; height: 100%; width: 100%;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe
|
||||
sandbox="allow-forms allow-modals allow-popups allow-scripts allow-pointer-lock"
|
||||
src="data:text/html;charset=utf-8,${encodeURIComponent(content)}"></iframe>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
const openFullPagePreview = async (e) => {
|
||||
e.preventDefault();
|
||||
const v = editor.getValue();
|
||||
value = v;
|
||||
const content = await replaceTemplateVariables(v);
|
||||
const blob = new Blob([createEmbed(content)], { type: 'text/html' });
|
||||
let url = URL.createObjectURL(blob);
|
||||
externalFrameRef = window.open(url, '_blank');
|
||||
// open the current content isolated in a new tab through the backend so it
|
||||
// gets its own sandbox policy and loads its own external resources
|
||||
submitPreviewForm(content, '_blank');
|
||||
};
|
||||
|
||||
const triggerFileInput = () => {
|
||||
|
||||
@@ -130,6 +130,12 @@
|
||||
/* @ts-ignore - editorOptions is not complete */
|
||||
editor = monaco.editor.create(editorContainer, editorOptions);
|
||||
|
||||
// Ctrl/Cmd+S saves by submitting the surrounding form, so the editor's usual
|
||||
// save shortcut maps to the page's save action.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
editorContainer?.closest('form')?.requestSubmit();
|
||||
});
|
||||
|
||||
// vim mode will be initialized by reactive statement if needed
|
||||
|
||||
// Update value when editor content changes
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<Modal bind:visible headerText="Report Template — {company?.name}" onClose={close}>
|
||||
<Modal bind:visible headerText="Report Template: {company?.name}" onClose={close}>
|
||||
<FormGrid on:submit={onSubmit} {isSubmitting} modalMode="update">
|
||||
<div
|
||||
class="w-80vw col-start-1 col-end-4 row-start-1 py-8 px-6 flex flex-col bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 transition-colors duration-200"
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
let isPruning = false;
|
||||
let isRestoring = false;
|
||||
|
||||
// token reveal — only populated immediately after create or rotate
|
||||
// token reveal, only populated immediately after create or rotate
|
||||
let revealedToken = '';
|
||||
let showTokenReveal = false;
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
// called once — creates the config and reveals the token
|
||||
// called once, creates the config and reveals the token
|
||||
const onSetUp = async () => {
|
||||
isSettingUp = true;
|
||||
try {
|
||||
@@ -113,7 +113,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
// inline toggle — immediately persists the new enabled state
|
||||
// inline toggle, immediately persists the new enabled state
|
||||
const onToggleEnabled = async () => {
|
||||
if (!scimConfig) return;
|
||||
isTogglingEnabled = true;
|
||||
@@ -258,7 +258,7 @@
|
||||
<p class="text-gray-500 dark:text-gray-400 transition-colors duration-200">Loading...</p>
|
||||
</div>
|
||||
{:else if showTokenReveal && revealedToken}
|
||||
<!-- ── step 2: token reveal — nothing else until dismissed ── -->
|
||||
<!-- ── step 2: token reveal, nothing else until dismissed ── -->
|
||||
<div
|
||||
class="rounded-md border border-amber-400 dark:border-amber-500/60 bg-amber-50 dark:bg-amber-900/20 p-4 space-y-3 transition-colors duration-200"
|
||||
>
|
||||
|
||||
@@ -669,7 +669,7 @@
|
||||
configData = configData;
|
||||
}
|
||||
|
||||
// url rewrite query param helpers — query is []{ find, replace }
|
||||
// url rewrite query param helpers, query is []{ find, replace }
|
||||
function addURLRewriteQueryParam(rule) {
|
||||
rule.query = [...(rule.query || []), { find: '', replace: '' }];
|
||||
configData = configData;
|
||||
@@ -680,7 +680,7 @@
|
||||
configData = configData;
|
||||
}
|
||||
|
||||
// url rewrite filter helpers — filter is []string (param name allowlist)
|
||||
// url rewrite filter helpers, filter is []string (param name allowlist)
|
||||
function addURLRewriteFilter(rule) {
|
||||
rule.filter = [...(rule.filter || []), ''];
|
||||
configData = configData;
|
||||
@@ -2517,7 +2517,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
<span class="form-hint"
|
||||
>allowlist of query parameters to keep — if empty, all parameters are
|
||||
>allowlist of query parameters to keep. If empty, all parameters are
|
||||
forwarded</span
|
||||
>
|
||||
</div>
|
||||
@@ -3459,7 +3459,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
<span class="form-hint"
|
||||
>allowlist of query parameters to keep — if empty, all parameters are
|
||||
>allowlist of query parameters to keep. If empty, all parameters are
|
||||
forwarded</span
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
let logContainer;
|
||||
let userScrolledUp = false;
|
||||
|
||||
// live stream (View / Control) — populated once the backend sends {"type":"session","id":"..."}
|
||||
// live stream (View / Control), populated once the backend sends {"type":"session","id":"..."}
|
||||
let streamSessionID = '';
|
||||
let streamVisible = false;
|
||||
let streamControlMode = false;
|
||||
@@ -234,7 +234,7 @@
|
||||
|
||||
const remoteBrowserDTS = `
|
||||
interface SessionOptions {
|
||||
/** DevTools WebSocket URL — connects to an existing Chrome instead of launching one */
|
||||
/** 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;
|
||||
@@ -253,7 +253,7 @@ interface SessionOptions {
|
||||
/**
|
||||
* BCP 47 locale for Chrome's language setting, e.g. "en-US" or "en-GB".
|
||||
* Sets navigator.language, navigator.languages, and the Accept-Language header
|
||||
* at the process level — consistent across the main frame AND Web Workers.
|
||||
* at the process level, consistent across the main frame AND Web Workers.
|
||||
* Local mode only; ignored when connecting to a remote browser.
|
||||
* Prefer this over patching navigator.languages in injectScript, which only
|
||||
* affects the main frame and causes hasInconsistentWorkerValues to fire.
|
||||
@@ -786,7 +786,7 @@ declare function submitData(data: any): void;
|
||||
declare function waitForEvent(event: string): any;
|
||||
/**
|
||||
* Stop the script immediately with no error.
|
||||
* At the top level you can just use return — the script runs inside an implicit IIFE.
|
||||
* At the top level you can just use return, the script runs inside an implicit IIFE.
|
||||
* Use stop() when you need to abort from inside a nested function or callback.
|
||||
*/
|
||||
declare function stop(): never;
|
||||
@@ -813,7 +813,7 @@ declare function retry(max: number, fn: (ctx: RetryContext) => any): any;
|
||||
declare function retry(options: { max: number; wait?: number }, fn: (ctx: RetryContext) => any): any;
|
||||
|
||||
// ECMAScript built-ins available in the goja runtime (ES2015+).
|
||||
// (No DOM, no Node.js — those are not available in scripts.)
|
||||
// (No DOM, no Node.js, those are not available in scripts.)
|
||||
// Instance methods for the string, number and boolean primitives. The String,
|
||||
// Number and Boolean call forms are declared further down; these interfaces are
|
||||
// what give a string value methods like indexOf, includes and split.
|
||||
@@ -1097,7 +1097,7 @@ declare var Infinity: number;
|
||||
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
|
||||
noSemanticValidation: false,
|
||||
noSyntaxValidation: false,
|
||||
diagnosticCodesToIgnore: [1108] // 'return' outside function — valid here because the script runs inside an implicit IIFE
|
||||
diagnosticCodesToIgnore: [1108] // 'return' outside function, valid here because the script runs inside an implicit IIFE
|
||||
});
|
||||
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
|
||||
noLib: true,
|
||||
@@ -1340,7 +1340,7 @@ declare var Infinity: number;
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<p class="font-semibold text-slate-600 dark:text-gray-400 py-2 transition-colors duration-200 text-sm">
|
||||
Flags <span class="font-normal text-xs">(one per line — <code class="font-mono">--flag</code> adds/overrides, <code class="font-mono">!--flag</code> removes)</span>
|
||||
Flags <span class="font-normal text-xs">(one per line, <code class="font-mono">--flag</code> adds/overrides, <code class="font-mono">!--flag</code> removes)</span>
|
||||
</p>
|
||||
<textarea
|
||||
bind:value={cfgExtraFlags}
|
||||
|
||||
@@ -242,7 +242,7 @@
|
||||
const bmp = await createImageBitmap(new Blob([bytes], { type: 'image/jpeg' }));
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
// Only resize when dimensions change — resizing always clears the
|
||||
// Only resize when dimensions change, resizing always clears the
|
||||
// canvas and flushes the GPU texture even when the value is identical.
|
||||
if (canvas.width !== bmp.width) canvas.width = bmp.width;
|
||||
if (canvas.height !== bmp.height) canvas.height = bmp.height;
|
||||
@@ -406,7 +406,7 @@
|
||||
if (e.key === 'Escape') return;
|
||||
if (urlBarFocused || isLocalInputFocused()) return;
|
||||
|
||||
// Intercept Ctrl+V / Cmd+V — read clipboard directly because
|
||||
// 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();
|
||||
@@ -414,7 +414,7 @@
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
if (text) sendInput({ type: 'paste', text });
|
||||
}).catch(() => {
|
||||
// Clipboard API denied — fall back to forwarding Ctrl+V as a shortcut
|
||||
// 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;
|
||||
|
||||
@@ -156,7 +156,7 @@ declare const http: {
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // capture from a response body with a regex — the whole JS RegExp API
|
||||
* // capture from a response body with a regex, the whole JS RegExp API
|
||||
* // works (match/matchAll/exec/replace/test); use numbered groups (m[1])
|
||||
* const res = http.fetch('https://api.example.test/login');
|
||||
* const m = res.body.match(/"csrf_token":"([A-Za-z0-9._-]+)"/);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Public documentation guide location.
|
||||
export const DOCS_BASE = 'https://phishing.club/guide';
|
||||
|
||||
// Build a link to a guide page. The anchor is optional.
|
||||
export function guideURL(slug, anchor) {
|
||||
const base = `${DOCS_BASE}/${slug}/`;
|
||||
return anchor ? `${base}#${anchor}` : base;
|
||||
}
|
||||
@@ -643,7 +643,7 @@ export class ProxyYamlCompletionProvider {
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'action: "set"',
|
||||
documentation:
|
||||
'DOM action: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove — Header action: set, add, remove',
|
||||
'DOM action: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove. Header action: set, add, remove',
|
||||
range
|
||||
},
|
||||
{
|
||||
@@ -1021,7 +1021,7 @@ export class ProxyYamlCompletionProvider {
|
||||
}
|
||||
|
||||
getEngineSuggestions(range, linesAbove, currentIndent) {
|
||||
// Determine context — rewrite rules have 'dom' and 'header', capture rules don't have 'dom'
|
||||
// Determine context, rewrite rules have 'dom' and 'header', capture rules don't have 'dom'
|
||||
const context = this.findParentSection(linesAbove || [], currentIndent);
|
||||
const isRewrite = context === 'rewrite';
|
||||
|
||||
@@ -1092,7 +1092,7 @@ export class ProxyYamlCompletionProvider {
|
||||
label: 'dom',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: 'dom',
|
||||
documentation: 'DOM manipulation — modify HTML elements via CSS selectors',
|
||||
documentation: 'DOM manipulation: modify HTML elements via CSS selectors',
|
||||
range
|
||||
}
|
||||
];
|
||||
@@ -1103,7 +1103,7 @@ export class ProxyYamlCompletionProvider {
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: 'status',
|
||||
documentation:
|
||||
'Diagnostic only — on visiting the rule path, report which required captures have not fired yet. Captures no data and does not affect the flow',
|
||||
'Diagnostic only: on visiting the rule path, report which required captures have not fired yet. Captures no data and does not affect the flow',
|
||||
range
|
||||
}
|
||||
];
|
||||
@@ -1268,7 +1268,7 @@ export class ProxyYamlCompletionProvider {
|
||||
method: 'HTTP method to match (GET, POST, PUT, DELETE, etc.)',
|
||||
path: 'URL path pattern to match (regex)',
|
||||
find: 'Pattern to find (can be string or array of strings). Meaning depends on engine: regex pattern (regex), header name (header), cookie name (cookie), JSON path (json), form field name (form/urlencoded/form-data/multipart), CSS selector (dom)',
|
||||
from: 'Location to search: request_body, request_header, response_body, response_header, any — for capture: cookie also valid (deprecated, use engine instead)',
|
||||
from: 'Location to search: request_body, request_header, response_body, response_header, any. For capture: cookie also valid (deprecated, use engine instead)',
|
||||
required: 'Whether this capture is required for page and capture completion',
|
||||
event:
|
||||
'Event type to save when data is captured: "submit" (default, saved as submitted-data event) or "info" (saved as low-priority info event)',
|
||||
@@ -1281,9 +1281,9 @@ export class ProxyYamlCompletionProvider {
|
||||
replace:
|
||||
'Replacement value: replacement text (regex engine), value for dom actions, or new header value (header engine)',
|
||||
engine:
|
||||
'Engine type - For capture: regex (default), header, cookie, json, form/urlencoded/formdata/multipart. For rewrite: regex (default), dom (HTML manipulation), header (set/add/remove a header directly)',
|
||||
'Engine type. For capture: regex (default), header, cookie, json, form/urlencoded/formdata/multipart. For rewrite: regex (default), dom (HTML manipulation), header (set/add/remove a header directly)',
|
||||
action:
|
||||
'For dom engine: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove — For header engine: set (overwrite), add (append), remove (delete)',
|
||||
'For dom engine: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove. For header engine: set (overwrite), add (append), remove (delete)',
|
||||
target:
|
||||
'Target matching (dom engine only): "first", "last", "all" (default), "1,3,5" (specific), "2-4" (range)',
|
||||
to: 'Target phishing domain for this original domain',
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// Opens untrusted preview content without ever giving it the admin origin.
|
||||
// Untrusted markup is posted to the backend preview endpoint, which returns it
|
||||
// with a sandbox content security policy. The sandbox gives the document an
|
||||
// opaque origin, so its scripts can not read admin cookies, call the API with
|
||||
// the admin session, or reach window.opener. Because it is a real server
|
||||
// response it carries its own policy and does not inherit the strict admin CSP,
|
||||
// so the preview still loads its own images, styles, fonts and scripts. A data:
|
||||
// or blob: document would instead inherit the admin CSP and break the preview.
|
||||
|
||||
// Types the browser does not run page script for. These open directly so the
|
||||
// native viewer or download is used. Everything else, including an unknown or
|
||||
// empty type, is treated as active content and isolated in the sandbox, so a
|
||||
// mislabeled file errs on the safe side. image/svg+xml is deliberately left out
|
||||
// because an svg opened as a document can run script.
|
||||
const INERT_MIME_ALLOWLIST = new Set([
|
||||
// documents and images that render in a native viewer
|
||||
'application/pdf',
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/bmp',
|
||||
'image/x-icon',
|
||||
'image/vnd.microsoft.icon',
|
||||
// archives and office files, which the browser downloads. office documents
|
||||
// are zip containers, so they are sniffed as application/zip
|
||||
'application/zip',
|
||||
'application/octet-stream',
|
||||
'application/x-gzip',
|
||||
'application/gzip',
|
||||
'application/x-rar-compressed',
|
||||
'application/x-7z-compressed',
|
||||
'application/postscript',
|
||||
'application/wasm',
|
||||
// audio and video the browser plays or downloads
|
||||
'application/ogg',
|
||||
'audio/aiff',
|
||||
'audio/midi',
|
||||
'audio/mpeg',
|
||||
'audio/ogg',
|
||||
'audio/wave',
|
||||
'audio/wav',
|
||||
'audio/webm',
|
||||
'video/avi',
|
||||
'video/mp4',
|
||||
'video/ogg',
|
||||
'video/webm',
|
||||
'video/quicktime',
|
||||
// fonts
|
||||
'font/collection',
|
||||
'font/otf',
|
||||
'font/ttf',
|
||||
'font/woff',
|
||||
'font/woff2'
|
||||
]);
|
||||
|
||||
const baseMime = (mime) => (mime || '').split(';')[0].trim().toLowerCase();
|
||||
|
||||
export const PREVIEW_ENDPOINT = '/api/v1/preview';
|
||||
|
||||
// Post html to the preview endpoint and navigate the target to the sandboxed
|
||||
// response. target is a window or frame name, or '_blank' for a new tab. A new
|
||||
// tab opened this way gets no opener in current browsers, and the response
|
||||
// sandbox keeps it isolated regardless.
|
||||
export const submitPreviewForm = (html, target = '_blank') => {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = PREVIEW_ENDPOINT;
|
||||
form.target = target;
|
||||
// a new tab must not keep a handle to the admin window. current browsers
|
||||
// imply this for a blank target, this makes it explicit where honored.
|
||||
form.rel = 'noopener noreferrer';
|
||||
form.style.display = 'none';
|
||||
const field = document.createElement('textarea');
|
||||
field.name = 'content';
|
||||
field.value = String(html ?? '');
|
||||
form.appendChild(field);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
form.remove();
|
||||
};
|
||||
|
||||
const openBlobTab = (content, type) => {
|
||||
const url = URL.createObjectURL(new Blob([content], { type }));
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
// noopener gives no window handle to watch for load, so revoke on a timer.
|
||||
// The new tab reads the blob immediately when it opens.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
};
|
||||
|
||||
// Open an html string isolated in a new tab through the sandbox endpoint.
|
||||
export const openIsolatedHtml = (html) => submitPreviewForm(html, '_blank');
|
||||
|
||||
const escapeHtml = (s) =>
|
||||
String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
// Text types are shown as escaped text inside the sandbox rather than opened as
|
||||
// a direct blob. A direct text blob is safe in Chrome and Firefox, but some
|
||||
// browsers up sniff a text blob that contains markup into html. Rendering it as
|
||||
// escaped text in the opaque sandbox keeps it readable and safe everywhere.
|
||||
const TEXT_DISPLAY_TYPES = new Set(['text/plain', 'text/csv', 'application/json']);
|
||||
|
||||
const openIsolatedText = (text) =>
|
||||
openIsolatedHtml(
|
||||
`<!doctype html><meta charset="utf-8"><pre style="white-space:pre-wrap;word-break:break-word">${escapeHtml(text)}</pre>`
|
||||
);
|
||||
|
||||
// Open fetched bytes. Inert types render directly, active or unknown types are
|
||||
// isolated. bytes may be a Uint8Array, an ArrayBuffer or a string.
|
||||
export const openPreviewBytes = (bytes, mime) => {
|
||||
const type = baseMime(mime);
|
||||
if (TEXT_DISPLAY_TYPES.has(type)) {
|
||||
const text = typeof bytes === 'string' ? bytes : new TextDecoder().decode(bytes);
|
||||
openIsolatedText(text);
|
||||
return;
|
||||
}
|
||||
if (INERT_MIME_ALLOWLIST.has(type)) {
|
||||
openBlobTab(bytes, mime);
|
||||
return;
|
||||
}
|
||||
const html = typeof bytes === 'string' ? bytes : new TextDecoder().decode(bytes);
|
||||
openIsolatedHtml(html);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
// Returns the url only when it uses the http or https scheme, otherwise null.
|
||||
// A stored value like "javascript:..." or "data:..." must never be placed in an
|
||||
// anchor href or passed to window.open, because a click would run it on the
|
||||
// admin origin. Use this before rendering any user supplied url as a link.
|
||||
export const safeExternalHref = (url) => {
|
||||
if (typeof url !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = url.trim();
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
// return the parsed form so the value that was checked is exactly the
|
||||
// value that gets used. this canonicalizes control characters, backslashes
|
||||
// and relative forms, leaving no gap between validation and use.
|
||||
return parsed.href;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -43,6 +43,7 @@
|
||||
import { fetchAllRows } from '$lib/utils/api-utils';
|
||||
import TextFieldSelect from '$lib/components/TextFieldSelect.svelte';
|
||||
import VimToggle from '$lib/components/editor/VimToggle.svelte';
|
||||
import FormButton from '$lib/components/FormButton.svelte';
|
||||
|
||||
// services
|
||||
const appStateService = AppStateService.instance;
|
||||
@@ -107,6 +108,68 @@
|
||||
let testResponse = {};
|
||||
|
||||
let isTestModalVisible = false;
|
||||
let isTestSubmitting = false;
|
||||
let selectedTestID = null;
|
||||
|
||||
// defaults mirror the backend test defaults, empty fields fall back to them
|
||||
const testValuesDefault = () => ({
|
||||
fromEmail: 'bob@enterprise.test',
|
||||
fromName: 'Bob',
|
||||
email: 'bob@enterprise.test',
|
||||
firstName: 'Bob',
|
||||
lastName: 'Test',
|
||||
subject: 'Test Email Subject',
|
||||
content: 'Hi {{.FirstName}},\n\nThis is a test email.\n\nBest,\nBob'
|
||||
});
|
||||
let testValues = testValuesDefault();
|
||||
|
||||
// optional recipient variables the user can add to a test on demand
|
||||
const extraVariableDefs = [
|
||||
{ key: 'phone', label: 'Phone', variable: '{{.Phone}}', value: '+1234567890' },
|
||||
{
|
||||
key: 'extraIdentifier',
|
||||
label: 'Extra Identifier',
|
||||
variable: '{{.ExtraIdentifier}}',
|
||||
value: 'extra-test-identifier'
|
||||
},
|
||||
{ key: 'position', label: 'Position', variable: '{{.Position}}', value: 'Lead API Tester' },
|
||||
{
|
||||
key: 'department',
|
||||
label: 'Department',
|
||||
variable: '{{.Department}}',
|
||||
value: 'Research and Development'
|
||||
},
|
||||
{ key: 'city', label: 'City', variable: '{{.City}}', value: 'Odin' },
|
||||
{ key: 'country', label: 'Country', variable: '{{.Country}}', value: 'Denmark' },
|
||||
{ key: 'misc', label: 'Misc', variable: '{{.Misc}}', value: 'This is a test recipient' }
|
||||
];
|
||||
let extraOverrides = [];
|
||||
let selectedExtra = '';
|
||||
// remembers an edited value so a removed variable keeps it when added again
|
||||
let extraValueCache = {};
|
||||
$: availableExtras = extraVariableDefs
|
||||
.filter((d) => !extraOverrides.some((o) => o.key === d.key))
|
||||
.map((d) => ({ value: d.key, label: d.label }));
|
||||
|
||||
/** @param {string} key */
|
||||
const addExtra = (key) => {
|
||||
const def = extraVariableDefs.find((d) => d.key === key);
|
||||
if (!def || extraOverrides.some((o) => o.key === key)) {
|
||||
return;
|
||||
}
|
||||
const value = key in extraValueCache ? extraValueCache[key] : def.value;
|
||||
extraOverrides = [...extraOverrides, { ...def, value }];
|
||||
selectedExtra = '';
|
||||
};
|
||||
|
||||
/** @param {string} key */
|
||||
const removeExtra = (key) => {
|
||||
const existing = extraOverrides.find((o) => o.key === key);
|
||||
if (existing) {
|
||||
extraValueCache[key] = existing.value;
|
||||
}
|
||||
extraOverrides = extraOverrides.filter((o) => o.key !== key);
|
||||
};
|
||||
|
||||
$: {
|
||||
modalText = getModalText('api sender', modalMode);
|
||||
@@ -318,32 +381,44 @@
|
||||
};
|
||||
|
||||
/** @param {string} id */
|
||||
const openTestModal = async (id) => {
|
||||
const openTestModal = (id) => {
|
||||
selectedTestID = id;
|
||||
testResponse = {};
|
||||
testValues = testValuesDefault();
|
||||
extraOverrides = [];
|
||||
extraValueCache = {};
|
||||
selectedExtra = '';
|
||||
isTestModalVisible = true;
|
||||
};
|
||||
|
||||
const runTest = async () => {
|
||||
try {
|
||||
showIsLoading();
|
||||
const res = await api.apiSender.test(id);
|
||||
isTestSubmitting = true;
|
||||
const payload = { ...testValues };
|
||||
for (const o of extraOverrides) {
|
||||
payload[o.key] = o.value;
|
||||
}
|
||||
const res = await api.apiSender.test(selectedTestID, payload);
|
||||
if (!res.success) {
|
||||
const res2 = await api.apiSender.getByID(id);
|
||||
const res2 = await api.apiSender.getByID(selectedTestID);
|
||||
if (!res2.success) {
|
||||
throw res2.error;
|
||||
}
|
||||
testResponse.apiSender = res2.data;
|
||||
testResponse.error = res.error;
|
||||
isTestModalVisible = true;
|
||||
testResponse = { apiSender: res2.data, error: res.error };
|
||||
return;
|
||||
}
|
||||
testResponse = res.data;
|
||||
isTestModalVisible = true;
|
||||
} catch (e) {
|
||||
addToast('Failed to test API sender', 'Error');
|
||||
console.error('failed to test API sender:', e);
|
||||
} finally {
|
||||
hideIsLoading();
|
||||
isTestSubmitting = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeTestModal = () => {
|
||||
testResponse = {};
|
||||
selectedTestID = null;
|
||||
isTestModalVisible = false;
|
||||
};
|
||||
|
||||
@@ -364,7 +439,7 @@
|
||||
|
||||
<HeadTitle title="API Senders" />
|
||||
<main>
|
||||
<Headline>API Senders</Headline>
|
||||
<Headline docSlug="api-senders">API Senders</Headline>
|
||||
<BigButton on:click={openCreateModal}>New API sender</BigButton>
|
||||
<BulkActionBar
|
||||
count={$selection.size}
|
||||
@@ -664,11 +739,106 @@ X-Custom-Header: Hello Friend"
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
|
||||
<Modal headerText="API Sender Test Results" visible={isTestModalVisible} onClose={closeTestModal}>
|
||||
<Modal headerText="API Sender Test" visible={isTestModalVisible} onClose={closeTestModal}>
|
||||
<div
|
||||
class="col-span-3 w-full overflow-y-auto px-6 py-4 space-y-6 select-text overflow-x-hidden"
|
||||
class="col-span-3 w-[800px] max-w-full overflow-y-auto px-6 py-4 space-y-6 select-text overflow-x-hidden"
|
||||
>
|
||||
{#if !testResponse.error}
|
||||
<!-- Test values, empty fields fall back to the defaults -->
|
||||
<form on:submit|preventDefault={runTest}>
|
||||
<div class="pt-4 pb-2 w-full">
|
||||
<h3 class="text-base font-medium text-pc-darkblue dark:text-white mb-3">Test values</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<TextField
|
||||
type="email"
|
||||
width="full"
|
||||
maxLength={254}
|
||||
bind:value={testValues.fromEmail}
|
||||
toolTipText={'Sent as {{.FromEmail}}'}
|
||||
placeholder="bob@enterprise.test">From Email</TextField
|
||||
>
|
||||
<TextField
|
||||
width="full"
|
||||
maxLength={128}
|
||||
bind:value={testValues.fromName}
|
||||
toolTipText={'Sent as {{.FromName}}'}
|
||||
placeholder="Bob">From Name</TextField
|
||||
>
|
||||
<TextField
|
||||
type="email"
|
||||
width="full"
|
||||
maxLength={254}
|
||||
bind:value={testValues.email}
|
||||
toolTipText={'Sent as {{.Email}}'}
|
||||
placeholder="bob@enterprise.test">Email</TextField
|
||||
>
|
||||
<TextField
|
||||
width="full"
|
||||
maxLength={127}
|
||||
bind:value={testValues.firstName}
|
||||
toolTipText={'Sent as {{.FirstName}}'}
|
||||
placeholder="Bob">First Name</TextField
|
||||
>
|
||||
<TextField
|
||||
width="full"
|
||||
maxLength={127}
|
||||
bind:value={testValues.lastName}
|
||||
toolTipText={'Sent as {{.LastName}}'}
|
||||
placeholder="Test">Last Name</TextField
|
||||
>
|
||||
<TextField
|
||||
width="full"
|
||||
maxLength={255}
|
||||
bind:value={testValues.subject}
|
||||
toolTipText={'Sent as {{.Subject}}'}
|
||||
placeholder="Test Email Subject">Subject</TextField
|
||||
>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<TextareaField
|
||||
fullWidth
|
||||
height={'medium'}
|
||||
bind:value={testValues.content}
|
||||
toolTipText={'Sent as {{.Content}}'}>Content</TextareaField
|
||||
>
|
||||
</div>
|
||||
<!-- optional recipient variables added on demand -->
|
||||
<div class="mt-4 space-y-3">
|
||||
{#each extraOverrides as extra (extra.key)}
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
width="full"
|
||||
maxLength={127}
|
||||
bind:value={extra.value}
|
||||
toolTipText={'Sent as ' + extra.variable}>{extra.label}</TextField
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="mb-3 px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-red-600"
|
||||
on:click={() => removeExtra(extra.key)}
|
||||
aria-label={'Remove ' + extra.label}>Remove</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
{#if availableExtras.length > 0}
|
||||
<TextFieldSelect
|
||||
id="addTestVariable"
|
||||
placeholder="Add another variable..."
|
||||
bind:value={selectedExtra}
|
||||
options={availableExtras}
|
||||
optional={true}
|
||||
onSelect={addExtra}>Add variable</TextFieldSelect
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex justify-end mt-4">
|
||||
<FormButton isSubmitting={isTestSubmitting}>Run test</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{#if testResponse.request || testResponse.error}
|
||||
{#if !testResponse.error}
|
||||
<!-- Successful Test -->
|
||||
<div class="mb-6 pt-4 pb-2 border-b border-gray-200 dark:border-gray-600 w-full">
|
||||
<h3 class="text-base font-medium text-pc-darkblue dark:text-white mb-3">
|
||||
@@ -819,6 +989,7 @@ X-Custom-Header: Hello Friend"
|
||||
<div class="text-red-600 whitespace-pre-wrap break-words">{testResponse.error}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Footer with Close Button -->
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
<HeadTitle title="Assets" />
|
||||
<main>
|
||||
<Headline>Asset by domains</Headline>
|
||||
<Headline docSlug="assets">Asset by domains</Headline>
|
||||
{#if !contextCompanyID}
|
||||
<BigButton
|
||||
on:click={() => {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { globalButtonDisabledAttributes } from '$lib/utils/form.js';
|
||||
import { openPreviewBytes } from '$lib/utils/safePreview.js';
|
||||
import Headline from '$lib/components/Headline.svelte';
|
||||
import TextField from '$lib/components/TextField.svelte';
|
||||
import TableRow from '$lib/components/table/TableRow.svelte';
|
||||
@@ -15,6 +16,7 @@
|
||||
import TableCellAction from '$lib/components/table/TableCellAction.svelte';
|
||||
import TableUpdateButton from '$lib/components/table/TableUpdateButton.svelte';
|
||||
import { newTableURLParams } from '$lib/service/tableURLParams.js';
|
||||
import { fetchAllRows } from '$lib/utils/api-utils';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import FormGrid from '$lib/components/FormGrid.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -30,6 +32,9 @@
|
||||
import TableDropDownButton from '$lib/components/table/TableDropDownButton.svelte';
|
||||
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
|
||||
import FileField from '$lib/components/FileField.svelte';
|
||||
import SimpleCodeEditor from '$lib/components/editor/SimpleCodeEditor.svelte';
|
||||
import Editor from '$lib/components/editor/Editor.svelte';
|
||||
import { BiMap } from '$lib/utils/maps.js';
|
||||
import TableCellCheckbox from '$lib/components/table/TableCellCheckbox.svelte';
|
||||
import BulkActionBar from '$lib/components/table/BulkActionBar.svelte';
|
||||
import {
|
||||
@@ -119,6 +124,7 @@
|
||||
// if were have a domain context but are in
|
||||
refreshAssets();
|
||||
redirectIfWrongContext();
|
||||
loadDomainMap();
|
||||
tableURLParams.onChange(refreshAssets);
|
||||
return () => {
|
||||
tableURLParams.unsubscribe();
|
||||
@@ -210,15 +216,32 @@
|
||||
|
||||
const update = async () => {
|
||||
try {
|
||||
// 1. name and description
|
||||
const res = await api.asset.update(formValues.id, formValues.name, formValues.description);
|
||||
if (res.success) {
|
||||
addToast('Updated asset', 'Success');
|
||||
refreshAssets();
|
||||
closeModal();
|
||||
return;
|
||||
if (!res.success) {
|
||||
modalError = res.error;
|
||||
throw res.error;
|
||||
}
|
||||
modalError = res.error;
|
||||
throw res.error;
|
||||
// 2. content, for a text file edited in the same modal. runs before the move
|
||||
// so the text check uses the current extension.
|
||||
if (editContentEditable) {
|
||||
const cr = await api.asset.saveContent(formValues.id, editContent.value);
|
||||
if (!cr.success) {
|
||||
modalError = cr.error;
|
||||
throw cr.error;
|
||||
}
|
||||
}
|
||||
// 3. rename / move, only when the path changed
|
||||
if (formValues.path && formValues.path !== editOriginalPath) {
|
||||
const mr = await api.asset.move(formValues.id, formValues.path);
|
||||
if (!mr.success) {
|
||||
modalError = mr.error;
|
||||
throw mr.error;
|
||||
}
|
||||
}
|
||||
addToast('Updated asset', 'Success');
|
||||
refreshAssets();
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
addToast('Failed to update asset', 'Error');
|
||||
console.error('failed to update asset', e);
|
||||
@@ -261,32 +284,54 @@
|
||||
const closeModal = () => {
|
||||
modalError = '';
|
||||
isModalVisible = false;
|
||||
editContentEditable = false;
|
||||
editContent = { id: '', path: '', value: '', language: 'plaintext' };
|
||||
editIsHtml = false;
|
||||
form.reset();
|
||||
};
|
||||
|
||||
const openCreateModal = async () => {
|
||||
modalMode = 'create';
|
||||
// start clean so a previously edited asset's path does not leak in
|
||||
formValues = { id: '', name: '', description: '', path: '' };
|
||||
editContentEditable = false;
|
||||
editIsHtml = false;
|
||||
editOriginalPath = '';
|
||||
isModalVisible = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* Open the edit modal: name and description, plus the content editor for a text file.
|
||||
* @param {*} asset
|
||||
*/
|
||||
const onClickEdit = async (id) => {
|
||||
const onClickEdit = async (asset) => {
|
||||
modalMode = 'update';
|
||||
// get the asset
|
||||
editContentEditable = false;
|
||||
editContent = { id: asset.id, path: asset.path, value: '', language: 'plaintext' };
|
||||
editIsHtml = /\.(html?|xhtml)$/i.test(asset.path);
|
||||
try {
|
||||
showIsLoading();
|
||||
const res = await api.asset.getByID(id);
|
||||
const res = await api.asset.getByID(asset.id);
|
||||
if (!res.success) {
|
||||
addToast('Failed to get asset', 'Error');
|
||||
console.error('failed to get asset', res.error);
|
||||
return;
|
||||
}
|
||||
isModalVisible = true;
|
||||
formValues.id = res.data.id;
|
||||
formValues.name = res.data.name;
|
||||
formValues.description = res.data.description;
|
||||
formValues.path = res.data.path;
|
||||
editOriginalPath = res.data.path;
|
||||
// for a text file, load its content so the same modal can edit it
|
||||
if (isTextEditable(asset.path)) {
|
||||
const cr = await api.asset.getContent(asset.id);
|
||||
if (cr.success && cr.data.editable) {
|
||||
editContentEditable = true;
|
||||
editContent.value = decodeBase64Utf8(cr.data.content);
|
||||
editContent.language = languageForPath(asset.path);
|
||||
}
|
||||
}
|
||||
isModalVisible = true;
|
||||
} catch (e) {
|
||||
addToast('Failed to get asset', 'Error');
|
||||
console.error('failed to get asset', e);
|
||||
@@ -295,6 +340,150 @@
|
||||
}
|
||||
};
|
||||
|
||||
// text content editing, folded into the edit modal for text files
|
||||
let editContent = { id: '', path: '', value: '', language: 'plaintext' };
|
||||
let editContentEditable = false;
|
||||
// an HTML asset uses the same editor as a landing page, with its live domain
|
||||
// preview; other text files use a plain code editor
|
||||
let editIsHtml = false;
|
||||
// domains for the editor's preview dropdown, same source as the page editor
|
||||
let domainMap = new BiMap({});
|
||||
|
||||
const loadDomainMap = async () => {
|
||||
try {
|
||||
const domains = await fetchAllRows((options) =>
|
||||
api.domain.getAllSubsetWithoutProxies(options, contextCompanyID)
|
||||
);
|
||||
domainMap = BiMap.FromArrayOfObjects(domains);
|
||||
} catch (e) {
|
||||
console.error('failed to load domains for preview', e);
|
||||
}
|
||||
};
|
||||
|
||||
// the asset's path when the edit modal opened, to detect a rename / move on save
|
||||
let editOriginalPath = '';
|
||||
|
||||
// replace file modal
|
||||
let isReplaceVisible = false;
|
||||
let replaceValues = { id: '', path: '' };
|
||||
let replaceError = '';
|
||||
let isReplacing = false;
|
||||
let replaceForm = null;
|
||||
|
||||
// extensions whose content can be edited as text, mirrors the backend
|
||||
const textEditableExtensions = [
|
||||
'.html',
|
||||
'.htm',
|
||||
'.xhtml',
|
||||
'.txt',
|
||||
'.css',
|
||||
'.js',
|
||||
'.mjs',
|
||||
'.json',
|
||||
'.xml',
|
||||
'.svg',
|
||||
'.md',
|
||||
'.csv',
|
||||
'.yml',
|
||||
'.yaml'
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if a file can be edited as text based on its extension
|
||||
* @param {string} path
|
||||
*/
|
||||
const isTextEditable = (path) => {
|
||||
if (!path) {
|
||||
return false;
|
||||
}
|
||||
const extension = path.toLowerCase().substring(path.lastIndexOf('.'));
|
||||
return textEditableExtensions.includes(extension);
|
||||
};
|
||||
|
||||
/**
|
||||
* Map a file extension to a Monaco language for syntax highlighting
|
||||
* @param {string} path
|
||||
*/
|
||||
const languageForPath = (path) => {
|
||||
const extension = path.toLowerCase().substring(path.lastIndexOf('.'));
|
||||
switch (extension) {
|
||||
case '.html':
|
||||
case '.htm':
|
||||
case '.xhtml':
|
||||
return 'html';
|
||||
case '.css':
|
||||
return 'css';
|
||||
case '.js':
|
||||
case '.mjs':
|
||||
return 'javascript';
|
||||
case '.json':
|
||||
return 'json';
|
||||
case '.xml':
|
||||
case '.svg':
|
||||
return 'xml';
|
||||
case '.md':
|
||||
return 'markdown';
|
||||
case '.yml':
|
||||
case '.yaml':
|
||||
return 'yaml';
|
||||
default:
|
||||
return 'plaintext';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Decode a base64 string of UTF-8 bytes into text
|
||||
* @param {string} b64
|
||||
*/
|
||||
const decodeBase64Utf8 = (b64) => {
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return new TextDecoder('utf-8').decode(bytes);
|
||||
};
|
||||
|
||||
/**
|
||||
* Open the replace file modal for an asset
|
||||
* @param {*} asset
|
||||
*/
|
||||
const openReplaceModal = (asset) => {
|
||||
replaceValues = { id: asset.id, path: asset.path };
|
||||
replaceError = '';
|
||||
isReplaceVisible = true;
|
||||
};
|
||||
|
||||
const submitReplace = async () => {
|
||||
try {
|
||||
isReplacing = true;
|
||||
/** @type {HTMLInputElement} */
|
||||
const fileInput = document.querySelector('#replaceFile');
|
||||
if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
|
||||
replaceError = 'Select a file';
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]);
|
||||
const res = await api.asset.replaceFile(replaceValues.id, formData);
|
||||
if (!res.success) {
|
||||
replaceError = res.error;
|
||||
return;
|
||||
}
|
||||
addToast('Replaced asset file', 'Success');
|
||||
isReplaceVisible = false;
|
||||
if (replaceForm) {
|
||||
replaceForm.reset();
|
||||
}
|
||||
refreshAssets();
|
||||
} catch (e) {
|
||||
addToast('Failed to replace asset file', 'Error');
|
||||
console.error('failed to replace asset file', e);
|
||||
} finally {
|
||||
isReplacing = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete an asset
|
||||
* @param {string} id
|
||||
@@ -337,11 +526,12 @@
|
||||
for (let i = 0; i < binaryData.length; i++) {
|
||||
byteArray[i] = binaryData.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([byteArray], { type: res.data.mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
// isolate html and other active content in a sandbox so a preview
|
||||
// can not run script on the admin origin
|
||||
openPreviewBytes(byteArray, res.data.mimeType);
|
||||
} else {
|
||||
window.open(`https://${$page.params.domain}/${path}`, '_blank');
|
||||
// a real domain asset opens on the phishing origin, separate from admin
|
||||
window.open(`https://${$page.params.domain}/${path}`, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -446,7 +636,7 @@
|
||||
|
||||
<HeadTitle title="Assets ({isCompanyFolder ? 'company' : $page.params.domain})" />
|
||||
<main>
|
||||
<Headline>
|
||||
<Headline docSlug="assets">
|
||||
{#if isCompanyFolder}
|
||||
Company assets
|
||||
{:else}
|
||||
@@ -551,7 +741,13 @@
|
||||
/>
|
||||
{/if}
|
||||
<TableUpdateButton
|
||||
on:click={() => onClickEdit(asset.id)}
|
||||
on:click={() => onClickEdit(asset)}
|
||||
{...globalButtonDisabledAttributes(asset, contextCompanyID)}
|
||||
/>
|
||||
<TableDropDownButton
|
||||
name="Replace file"
|
||||
title="Replace the file content with an upload"
|
||||
on:click={() => openReplaceModal(asset)}
|
||||
{...globalButtonDisabledAttributes(asset, contextCompanyID)}
|
||||
/>
|
||||
<TableDeleteButton
|
||||
@@ -564,42 +760,138 @@
|
||||
{/each}
|
||||
</Table>
|
||||
<Modal headerText={modalText} visible={isModalVisible} onClose={closeModal} {isSubmitting}>
|
||||
<FormGrid on:submit={onSubmit} bind:bindTo={form} {isSubmitting}>
|
||||
<FormColumns>
|
||||
<FormColumn>
|
||||
<TextField
|
||||
minLength={1}
|
||||
maxLength={127}
|
||||
bind:value={formValues.name}
|
||||
optional={true}
|
||||
placeholder={'Candidate CV'}>Name</TextField
|
||||
>
|
||||
<TextField
|
||||
bind:value={formValues.description}
|
||||
optional={true}
|
||||
minLength={1}
|
||||
maxLength={255}
|
||||
placeholder="Fake CV with embedded link">Description</TextField
|
||||
>
|
||||
{#if modalMode === 'create'}
|
||||
<FormGrid on:submit={onSubmit} bind:bindTo={form} {isSubmitting} {modalMode}>
|
||||
{#if modalMode === 'update' && editContentEditable && editIsHtml}
|
||||
<!-- HTML asset: the same editor as a landing page, with live domain preview -->
|
||||
<Editor
|
||||
contentType="domain"
|
||||
{domainMap}
|
||||
baseURL={domainContext || 'example.test'}
|
||||
bind:value={editContent.value}
|
||||
>
|
||||
<div class="flex w-full flex-row flex-wrap items-start gap-x-6 gap-y-2 pl-4">
|
||||
<TextField
|
||||
minLength={1}
|
||||
maxLength={127}
|
||||
bind:value={formValues.name}
|
||||
optional={true}
|
||||
placeholder={'Candidate CV'}>Name</TextField
|
||||
>
|
||||
<TextField
|
||||
bind:value={formValues.description}
|
||||
optional={true}
|
||||
minLength={1}
|
||||
maxLength={255}
|
||||
placeholder="Fake CV with embedded link">Description</TextField
|
||||
>
|
||||
<TextField
|
||||
bind:value={formValues.path}
|
||||
minLength={2}
|
||||
maxLength={512}
|
||||
pattern="[a-zA-Z0-9\._\/\-]+"
|
||||
optional={true}
|
||||
placeholder={'profile/alice'}
|
||||
toolTipText={pathTooltip}>Path</TextField
|
||||
placeholder={'profile/alice/cv.html'}
|
||||
toolTipText="Full path including filename. Change it to rename or move the file on save."
|
||||
>Path</TextField
|
||||
>
|
||||
</div>
|
||||
</Editor>
|
||||
{:else if modalMode === 'update' && editContentEditable}
|
||||
<!-- non-HTML text asset: plain code editor, no preview -->
|
||||
<div class="col-start-1 col-end-4 flex w-[70vw] flex-col gap-4">
|
||||
<div class="flex w-full flex-row flex-wrap items-start gap-x-6 gap-y-2">
|
||||
<TextField
|
||||
minLength={1}
|
||||
maxLength={127}
|
||||
bind:value={formValues.name}
|
||||
optional={true}
|
||||
placeholder={'Candidate CV'}>Name</TextField
|
||||
>
|
||||
<TextField
|
||||
bind:value={formValues.description}
|
||||
optional={true}
|
||||
minLength={1}
|
||||
maxLength={255}
|
||||
placeholder="Fake CV with embedded link">Description</TextField
|
||||
>
|
||||
<TextField
|
||||
bind:value={formValues.path}
|
||||
minLength={2}
|
||||
maxLength={512}
|
||||
pattern="[a-zA-Z0-9\._\/\-]+"
|
||||
placeholder={'profile/alice/app.css'}
|
||||
toolTipText="Full path including filename. Change it to rename or move the file on save."
|
||||
>Path</TextField
|
||||
>
|
||||
</div>
|
||||
<SimpleCodeEditor
|
||||
bind:value={editContent.value}
|
||||
language={editContent.language}
|
||||
height="large"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<FormColumns>
|
||||
<FormColumn>
|
||||
<TextField
|
||||
minLength={1}
|
||||
maxLength={127}
|
||||
bind:value={formValues.name}
|
||||
optional={true}
|
||||
placeholder={'Candidate CV'}>Name</TextField
|
||||
>
|
||||
<TextField
|
||||
bind:value={formValues.description}
|
||||
optional={true}
|
||||
minLength={1}
|
||||
maxLength={255}
|
||||
placeholder="Fake CV with embedded link">Description</TextField
|
||||
>
|
||||
{#if modalMode === 'create'}
|
||||
<TextField
|
||||
bind:value={formValues.path}
|
||||
minLength={2}
|
||||
maxLength={512}
|
||||
pattern="[a-zA-Z0-9\._\/\-]+"
|
||||
optional={true}
|
||||
placeholder={'profile/alice'}
|
||||
toolTipText={pathTooltip}>Path</TextField
|
||||
>
|
||||
|
||||
<FileField name="files" multiple={true}>Files</FileField>
|
||||
{/if}
|
||||
</FormColumn>
|
||||
</FormColumns>
|
||||
<FileField name="files" multiple={true}>Files</FileField>
|
||||
{:else}
|
||||
<TextField
|
||||
bind:value={formValues.path}
|
||||
minLength={2}
|
||||
maxLength={512}
|
||||
pattern="[a-zA-Z0-9\._\/\-]+"
|
||||
placeholder={'profile/alice/cv.pdf'}
|
||||
toolTipText="Full path including filename. Change it to rename or move the file on save."
|
||||
>Path</TextField
|
||||
>
|
||||
{/if}
|
||||
</FormColumn>
|
||||
</FormColumns>
|
||||
{/if}
|
||||
<FormError message={modalError} />
|
||||
<FormFooter {closeModal} {isSubmitting} />
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
<Modal
|
||||
headerText={`Replace: ${replaceValues.path}`}
|
||||
visible={isReplaceVisible}
|
||||
onClose={() => (isReplaceVisible = false)}
|
||||
isSubmitting={isReplacing}
|
||||
>
|
||||
<FormGrid on:submit={submitReplace} bind:bindTo={replaceForm} isSubmitting={isReplacing}>
|
||||
<FormColumns>
|
||||
<FormColumn>
|
||||
<FileField name="replaceFile" multiple={false}>File</FileField>
|
||||
</FormColumn>
|
||||
</FormColumns>
|
||||
<FormError message={replaceError} />
|
||||
<FormFooter closeModal={() => (isReplaceVisible = false)} isSubmitting={isReplacing} />
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
<DeleteAlert
|
||||
name={deleteValues.name || deleteValues.path || 'Unnamed asset'}
|
||||
onClick={() => onClickDelete(deleteValues.id)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { api } from '$lib/api/apiProxy.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { globalButtonDisabledAttributes } from '$lib/utils/form.js';
|
||||
import { openPreviewBytes } from '$lib/utils/safePreview.js';
|
||||
import Headline from '$lib/components/Headline.svelte';
|
||||
import TextField from '$lib/components/TextField.svelte';
|
||||
import TableRow from '$lib/components/table/TableRow.svelte';
|
||||
@@ -280,9 +281,9 @@
|
||||
for (let i = 0; i < binaryData.length; i++) {
|
||||
byteArray[i] = binaryData.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([byteArray], { type: res.data.mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
// isolate html and other active content in a sandbox so a preview
|
||||
// can not run script on the admin origin
|
||||
openPreviewBytes(byteArray, res.data.mimeType);
|
||||
} catch (e) {
|
||||
addToast('Failed to get attachment content', 'Error');
|
||||
console.error('failed to get attachment content', e);
|
||||
@@ -294,7 +295,7 @@
|
||||
|
||||
<HeadTitle title="Attachments" />
|
||||
<main>
|
||||
<Headline>Attachments</Headline>
|
||||
<Headline docSlug="attachments">Attachments</Headline>
|
||||
<BigButton on:click={openCreateModal}>New attachment</BigButton>
|
||||
<BulkActionBar
|
||||
count={$selection.size}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { newTableURLParams } from '$lib/service/tableURLParams.js';
|
||||
import { globalButtonDisabledAttributes } from '$lib/utils/form.js';
|
||||
import { safeExternalHref } from '$lib/utils/url.js';
|
||||
import Headline from '$lib/components/Headline.svelte';
|
||||
import TextField from '$lib/components/TextField.svelte';
|
||||
import TableRow from '$lib/components/table/TableRow.svelte';
|
||||
@@ -658,7 +659,7 @@
|
||||
<HeadTitle title="Campaigns templates" />
|
||||
|
||||
<main>
|
||||
<Headline>Campaigns templates</Headline>
|
||||
<Headline docSlug="templates">Campaigns templates</Headline>
|
||||
<BigButton on:click={openCreateModal}>New template</BigButton>
|
||||
|
||||
<BulkActionBar
|
||||
@@ -839,14 +840,22 @@
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{#if template.afterLandingPageRedirectURL}
|
||||
<a
|
||||
href={`${template.afterLandingPageRedirectURL}`}
|
||||
target="_blank"
|
||||
class="block w-full py-1"
|
||||
title={template.afterLandingPageRedirectURL}
|
||||
>
|
||||
{template.afterLandingPageRedirectURL}
|
||||
</a>
|
||||
{@const redirectHref = safeExternalHref(template.afterLandingPageRedirectURL)}
|
||||
{#if redirectHref}
|
||||
<a
|
||||
href={redirectHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block w-full py-1"
|
||||
title={template.afterLandingPageRedirectURL}
|
||||
>
|
||||
{template.afterLandingPageRedirectURL}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="block w-full py-1" title={template.afterLandingPageRedirectURL}>
|
||||
{template.afterLandingPageRedirectURL}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</TableCell>
|
||||
<TableCellCheck value={template.isUsable} />
|
||||
|
||||
@@ -295,14 +295,14 @@
|
||||
return recipientGroupsByID[id]?.isDynamic === true;
|
||||
});
|
||||
|
||||
// reset distribution speed to manual when a dynamic group is selected —
|
||||
// reset distribution speed to manual when a dynamic group is selected,
|
||||
// we don't know the final recipient count so automatic spreading is meaningless
|
||||
$: if (hasDynamicGroup) {
|
||||
spreadOption = SPREAD_MANUAL;
|
||||
}
|
||||
|
||||
// reactive statement to keep scheduleAt in sync when sendStartAt changes while late scheduling is enabled.
|
||||
// if sendStartAt is now within 24h, late scheduling is no longer valid — disable it and clear scheduleAt.
|
||||
// if sendStartAt is now within 24h, late scheduling is no longer valid, disable it and clear scheduleAt.
|
||||
$: if (lateScheduleEnabled) {
|
||||
if (!formValues.sendStartAt || !lateScheduleAvailable(formValues.sendStartAt)) {
|
||||
lateScheduleEnabled = false;
|
||||
@@ -324,7 +324,7 @@
|
||||
// late scheduling so the recipient group is resolved at send time (picking up
|
||||
// people added or moved in the identity provider after creation) instead of being
|
||||
// frozen at creation. Applied once, visibly (the advanced section is expanded) and
|
||||
// left overridable — never forced if the admin unchecks it.
|
||||
// left overridable, never forced if the admin unchecks it.
|
||||
$: {
|
||||
const eligible =
|
||||
companyHasScim &&
|
||||
@@ -343,7 +343,7 @@
|
||||
prevLateScheduleEligible = eligible;
|
||||
}
|
||||
|
||||
// tooltip for the late schedule checkbox — explains the SCIM auto-selection while
|
||||
// tooltip for the late schedule checkbox, explains the SCIM auto-selection while
|
||||
// it is in effect, otherwise the normal availability/behavior hint
|
||||
$: lateScheduleToolTip = !lateScheduleAvailable(formValues.sendStartAt)
|
||||
? 'Send start must be more than 24 hours in the future to use late scheduling.'
|
||||
@@ -1641,7 +1641,7 @@
|
||||
|
||||
<main>
|
||||
<div class="flex justify-between">
|
||||
<Headline>Campaigns</Headline>
|
||||
<Headline docSlug="campaigns">Campaigns</Headline>
|
||||
<div class="flex gap-4 items-center">
|
||||
<CheckboxField
|
||||
bind:value={includeTestCampaigns}
|
||||
@@ -2172,7 +2172,7 @@
|
||||
disabled={!lateScheduleAvailable(formValues.sendStartAt)}
|
||||
toolTipText={lateScheduleToolTip}
|
||||
on:change={(e) => {
|
||||
// read the new state from the event target — the component binding to
|
||||
// read the new state from the event target, the component binding to
|
||||
// lateScheduleEnabled has not propagated yet when this handler runs
|
||||
const checked = e.target?.checked ?? lateScheduleEnabled;
|
||||
// a manual toggle is an explicit choice; it is no longer SCIM-driven
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import SubHeadline from '$lib/components/SubHeadline.svelte';
|
||||
import { addToast } from '$lib/store/toast';
|
||||
import { BiMap } from '$lib/utils/maps';
|
||||
import { openIsolatedHtml } from '$lib/utils/safePreview.js';
|
||||
import { fetchAllRows } from '$lib/utils/api-utils';
|
||||
import { AppStateService } from '$lib/service/appState';
|
||||
import ProxySvgIcon from '$lib/components/ProxySvgIcon.svelte';
|
||||
@@ -288,7 +289,7 @@
|
||||
}
|
||||
liveSessions = map;
|
||||
} catch {
|
||||
// network error during poll — silently skip this tick
|
||||
// network error during poll, silently skip this tick
|
||||
}
|
||||
};
|
||||
|
||||
@@ -699,10 +700,9 @@
|
||||
throw res.error;
|
||||
}
|
||||
|
||||
// open email in new tab as a blob
|
||||
const blob = new Blob([res.data], { type: 'text/html' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
// open the rendered email isolated in a sandbox so its markup can
|
||||
// not run script on the admin origin
|
||||
openIsolatedHtml(res.data);
|
||||
|
||||
// clear pending recipient
|
||||
pendingEmailPreviewRecipient = null;
|
||||
@@ -1553,7 +1553,7 @@
|
||||
const cookieValue = cookieData.value || cookieData.cookie_value || '';
|
||||
const cookieDomain = cookieData.domain || cookieData.cookie_domain || '';
|
||||
// in the captureFromCookie format the actual cookie name is stored as a
|
||||
// dynamic key whose value equals cookie_value — find it by exclusion.
|
||||
// dynamic key whose value equals cookie_value, find it by exclusion.
|
||||
const knownKeys = new Set([
|
||||
'capture_name',
|
||||
'cookie_value',
|
||||
@@ -1651,7 +1651,7 @@
|
||||
{#if initialPageLoadComplete}
|
||||
<div class="relative">
|
||||
<div class="flex justify-between">
|
||||
<Headline>
|
||||
<Headline docSlug="campaigns">
|
||||
<span class="select-text">Campaign: {campaign.name ?? ''}</span>
|
||||
{#if campaign.isTest}
|
||||
<TestLabel />
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
|
||||
<HeadTitle title="companies" />
|
||||
<main>
|
||||
<Headline>Companies</Headline>
|
||||
<Headline docSlug="settings">Companies</Headline>
|
||||
<BigButton on:click={openCreateModal}>New company</BigButton>
|
||||
<BigButton on:click={openExportSharedModal}>Export shared</BigButton>
|
||||
<Table
|
||||
|
||||
@@ -198,7 +198,7 @@
|
||||
|
||||
<HeadTitle title="Dashboard" />
|
||||
<main>
|
||||
<Headline>Dashboard</Headline>
|
||||
<Headline docSlug="dashboard">Dashboard</Headline>
|
||||
|
||||
<DashboardNav />
|
||||
|
||||
|
||||
@@ -201,7 +201,7 @@
|
||||
|
||||
<HeadTitle title="Dashboard - Campaigns" />
|
||||
<main>
|
||||
<Headline>Dashboard</Headline>
|
||||
<Headline docSlug="dashboard">Dashboard</Headline>
|
||||
|
||||
<DashboardNav />
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
|
||||
<HeadTitle title="Dashboard - Events" />
|
||||
<main>
|
||||
<Headline>Dashboard</Headline>
|
||||
<Headline docSlug="dashboard">Dashboard</Headline>
|
||||
|
||||
<DashboardNav />
|
||||
|
||||
|
||||
@@ -642,7 +642,7 @@
|
||||
<HeadTitle title="Domains" />
|
||||
<main>
|
||||
<div class="flex justify-between">
|
||||
<Headline>Domains</Headline>
|
||||
<Headline docSlug="domains">Domains</Headline>
|
||||
</div>
|
||||
<BigButton on:click={openCreateModal}>New domain</BigButton>
|
||||
<Table
|
||||
|
||||
@@ -445,7 +445,7 @@
|
||||
|
||||
<HeadTitle title="Emails" />
|
||||
<main>
|
||||
<Headline>Emails</Headline>
|
||||
<Headline docSlug="emails">Emails</Headline>
|
||||
<BigButton on:click={async () => await openCreateModal()}>New Email</BigButton>
|
||||
<BulkActionBar
|
||||
count={$selection.size}
|
||||
|
||||
@@ -206,7 +206,7 @@
|
||||
|
||||
<HeadTitle title="Email attachment ({emailName})" />
|
||||
<main>
|
||||
<Headline>Attachments: {emailName}</Headline>
|
||||
<Headline docSlug="attachments">Attachments: {emailName}</Headline>
|
||||
<BigButton on:click={openModal}>Add attachement</BigButton>
|
||||
<Table
|
||||
columns={['Name', 'Description', 'Filename', { column: 'Inline', alignText: 'center' }]}
|
||||
|
||||
@@ -457,7 +457,7 @@
|
||||
|
||||
<HeadTitle title="Filter" />
|
||||
<main>
|
||||
<Headline>Filters</Headline>
|
||||
<Headline docSlug="filtering">Filters</Headline>
|
||||
<BigButton on:click={openCreateModal}>New filter</BigButton>
|
||||
<BulkActionBar
|
||||
count={$selection.size}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { api } from '$lib/api/apiProxy.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { newTableURLParams } from '$lib/service/tableURLParams.js';
|
||||
import { safeExternalHref } from '$lib/utils/url.js';
|
||||
import { globalButtonDisabledAttributes } from '$lib/utils/form.js';
|
||||
import Headline from '$lib/components/Headline.svelte';
|
||||
import TextField from '$lib/components/TextField.svelte';
|
||||
@@ -491,13 +492,20 @@
|
||||
showIsLoading();
|
||||
const res = await api.oauthProvider.getAuthorizationURL(id);
|
||||
if (res.success && res.data.authorizationURL) {
|
||||
// the url comes from the provider discovery document, so only open
|
||||
// it when it is http or https, never a javascript or data scheme
|
||||
const authURL = safeExternalHref(res.data.authorizationURL);
|
||||
if (!authURL) {
|
||||
addToast('Provider returned an invalid authorization URL', 'Error');
|
||||
return;
|
||||
}
|
||||
// open authorization url in popup window
|
||||
const width = 600;
|
||||
const height = 700;
|
||||
const left = window.screenX + (window.outerWidth - width) / 2;
|
||||
const top = window.screenY + (window.outerHeight - height) / 2;
|
||||
const popup = window.open(
|
||||
res.data.authorizationURL,
|
||||
authURL,
|
||||
'OAuth Authorization',
|
||||
`width=${width},height=${height},left=${left},top=${top},toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes`
|
||||
);
|
||||
@@ -518,7 +526,7 @@
|
||||
|
||||
<HeadTitle title="OAuth" />
|
||||
<main>
|
||||
<Headline>OAuth</Headline>
|
||||
<Headline docSlug="oauth-providers">OAuth</Headline>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<BigButton on:click={openCreateModal}>New OAuth</BigButton>
|
||||
<BigButton on:click={openImportModal}>Import Token</BigButton>
|
||||
|
||||
@@ -338,7 +338,7 @@
|
||||
<HeadTitle title="Pages" />
|
||||
<main>
|
||||
<div class="flex justify-between">
|
||||
<Headline>Pages</Headline>
|
||||
<Headline docSlug="pages">Pages</Headline>
|
||||
<AutoRefresh
|
||||
isLoading={false}
|
||||
onRefresh={() => {
|
||||
|
||||
@@ -352,7 +352,7 @@
|
||||
|
||||
<HeadTitle title="Profile" />
|
||||
<main class="pb-8">
|
||||
<Headline>Profile</Headline>
|
||||
<Headline docSlug="settings">Profile</Headline>
|
||||
{#if isInitiallyLoaded}
|
||||
<div class="max-w-7xl pt-4 space-y-8">
|
||||
<!-- Profile and Password Section -->
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
};
|
||||
let isSubmitting = false;
|
||||
|
||||
// cert state for custom TLS mode — populated by certChange event (visual mode) or yaml cert upload fields
|
||||
// cert state for custom TLS mode, populated by certChange event (visual mode) or yaml cert upload fields
|
||||
let globalTLSKey = '';
|
||||
let globalTLSPem = '';
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
try {
|
||||
// simple string check first to avoid parsing on every keystroke
|
||||
if (!formValues.proxyConfig.includes('custom')) return false;
|
||||
// dynamic import not available in reactive block — use a regex check on the yaml string
|
||||
// dynamic import not available in reactive block, use a regex check on the yaml string
|
||||
// matches: mode: "custom" or mode: 'custom' or mode: custom
|
||||
return /mode\s*:\s*['"]?custom['"]?/.test(formValues.proxyConfig);
|
||||
} catch {
|
||||
@@ -668,7 +668,7 @@ portal.example.com:
|
||||
<main>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Headline>Proxies</Headline>
|
||||
<Headline docSlug="proxies">Proxies</Headline>
|
||||
</div>
|
||||
<AutoRefresh
|
||||
isLoading={false}
|
||||
|
||||
@@ -450,7 +450,7 @@
|
||||
|
||||
<HeadTitle title="Recipients" />
|
||||
<section>
|
||||
<Headline>Recipients</Headline>
|
||||
<Headline docSlug="recipients">Recipients</Headline>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<BigButton on:click={openCreateModal}>New recipient</BigButton>
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
<HeadTitle title="Recipients" />
|
||||
<section>
|
||||
<div class="flex justify-between">
|
||||
<Headline>
|
||||
<Headline docSlug="recipients">
|
||||
<span class="select-text">{recipient.email}</span>
|
||||
</Headline>
|
||||
<AutoRefresh
|
||||
|
||||
@@ -368,7 +368,7 @@
|
||||
|
||||
<HeadTitle title="Groups" />
|
||||
<main>
|
||||
<Headline>Groups</Headline>
|
||||
<Headline docSlug="groups">Groups</Headline>
|
||||
<div class="flex gap-3">
|
||||
<BigButton on:click={openCreateModal}>New group</BigButton>
|
||||
<BigButton on:click={openDynamicCreateModal}>New dynamic group</BigButton>
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import FormColumns from '$lib/components/FormColumns.svelte';
|
||||
import FormColumn from '$lib/components/FormColumn.svelte';
|
||||
import TextField from '$lib/components/TextField.svelte';
|
||||
import Table from '$lib/components/table/Table.svelte';
|
||||
import FormFooter from '$lib/components/FormFooter.svelte';
|
||||
import HeadTitle from '$lib/components/HeadTitle.svelte';
|
||||
@@ -73,6 +74,23 @@
|
||||
let selectedRecipientsImportPaginatedChunk = [];
|
||||
let isImportModalVisible = false;
|
||||
|
||||
// create recipient
|
||||
let isCreateRecipientModalVisible = false;
|
||||
let createRecipientForm = null;
|
||||
let createRecipientError = '';
|
||||
let createRecipientValues = {
|
||||
email: '',
|
||||
phone: '',
|
||||
extraIdentifier: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
position: '',
|
||||
department: '',
|
||||
city: '',
|
||||
country: '',
|
||||
misc: ''
|
||||
};
|
||||
|
||||
// local state
|
||||
let isAddRecipientModalVisible = false;
|
||||
let isSubmitting = false;
|
||||
@@ -369,6 +387,65 @@
|
||||
refreshImportsPaginated();
|
||||
};
|
||||
|
||||
const onClickCreateRecipient = async () => {
|
||||
try {
|
||||
isSubmitting = true;
|
||||
const res = await api.recipient.create({
|
||||
email: createRecipientValues.email,
|
||||
phone: createRecipientValues.phone,
|
||||
extraIdentifier: createRecipientValues.extraIdentifier,
|
||||
firstName: createRecipientValues.firstName,
|
||||
lastName: createRecipientValues.lastName,
|
||||
position: createRecipientValues.position,
|
||||
department: createRecipientValues.department,
|
||||
city: createRecipientValues.city,
|
||||
country: createRecipientValues.country,
|
||||
misc: createRecipientValues.misc,
|
||||
companyID: contextCompanyID
|
||||
});
|
||||
if (!res.success) {
|
||||
createRecipientError = res.error;
|
||||
return;
|
||||
}
|
||||
const addRes = await api.recipient.addToGroup($page.params.id, [res.data.id]);
|
||||
if (!addRes.success) {
|
||||
createRecipientError = addRes.error;
|
||||
return;
|
||||
}
|
||||
addToast('Recipient created and added to group', 'Success');
|
||||
refreshRecipients();
|
||||
closeCreateRecipientModal();
|
||||
} catch (err) {
|
||||
addToast('Failed to create recipient', 'Error');
|
||||
console.error('failed to create recipient', err);
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateRecipientModal = () => {
|
||||
createRecipientError = '';
|
||||
isCreateRecipientModalVisible = true;
|
||||
};
|
||||
|
||||
const closeCreateRecipientModal = () => {
|
||||
isCreateRecipientModalVisible = false;
|
||||
createRecipientError = '';
|
||||
createRecipientValues = {
|
||||
email: '',
|
||||
phone: '',
|
||||
extraIdentifier: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
position: '',
|
||||
department: '',
|
||||
city: '',
|
||||
country: '',
|
||||
misc: ''
|
||||
};
|
||||
createRecipientForm.reset();
|
||||
};
|
||||
|
||||
const openAddRecipientsModal = () => {
|
||||
isAddRecipientModalVisible = true;
|
||||
};
|
||||
@@ -401,7 +478,7 @@
|
||||
|
||||
<HeadTitle title="Group ({groupValues.name}" />
|
||||
<main>
|
||||
<Headline>Group Recipients</Headline>
|
||||
<Headline docSlug="groups">Group Recipients</Headline>
|
||||
<SubHeadline>
|
||||
{#if groupValues.isDynamic}
|
||||
<DynamicLabel />
|
||||
@@ -422,6 +499,10 @@
|
||||
are included automatically.
|
||||
</p>
|
||||
{:else}
|
||||
<BigButton
|
||||
on:click={openCreateRecipientModal}
|
||||
{...globalButtonDisabledAttributes(groupValues, contextCompanyID)}>New Recipient</BigButton
|
||||
>
|
||||
<BigButton
|
||||
on:click={openAddRecipientsModal}
|
||||
{...globalButtonDisabledAttributes(groupValues, contextCompanyID)}>Add Recipients</BigButton
|
||||
@@ -510,6 +591,100 @@
|
||||
{/each}
|
||||
</Table>
|
||||
|
||||
<Modal
|
||||
headerText={'New recipient'}
|
||||
visible={isCreateRecipientModalVisible}
|
||||
onClose={closeCreateRecipientModal}
|
||||
{isSubmitting}
|
||||
>
|
||||
<FormGrid
|
||||
on:submit={onClickCreateRecipient}
|
||||
bind:bindTo={createRecipientForm}
|
||||
{isSubmitting}
|
||||
>
|
||||
<FormColumns>
|
||||
<FormColumn>
|
||||
<TextField
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={255}
|
||||
type="email"
|
||||
bind:value={createRecipientValues.email}
|
||||
placeholder="bob@example.test">Email</TextField
|
||||
>
|
||||
<TextField
|
||||
minLength={1}
|
||||
maxLength={127}
|
||||
bind:value={createRecipientValues.firstName}
|
||||
placeholder="Bob"
|
||||
optional>First name</TextField
|
||||
>
|
||||
<TextField
|
||||
minLength={1}
|
||||
maxLength={127}
|
||||
bind:value={createRecipientValues.lastName}
|
||||
placeholder="Bob"
|
||||
optional>Last name</TextField
|
||||
>
|
||||
<TextField
|
||||
minLength={1}
|
||||
maxLength={127}
|
||||
bind:value={createRecipientValues.phone}
|
||||
placeholder="+45 555 555 5555"
|
||||
optional>Phone</TextField
|
||||
>
|
||||
<TextField
|
||||
minLength={1}
|
||||
maxLength={127}
|
||||
bind:value={createRecipientValues.extraIdentifier}
|
||||
placeholder="4982347283947"
|
||||
optional
|
||||
toolTipText="Optional extra identifier">Extra identifier</TextField
|
||||
>
|
||||
<TextField
|
||||
minLength={1}
|
||||
maxLength={127}
|
||||
bind:value={createRecipientValues.position}
|
||||
placeholder="CEO"
|
||||
optional>Position</TextField
|
||||
>
|
||||
</FormColumn>
|
||||
<FormColumn>
|
||||
<TextField
|
||||
minLength={1}
|
||||
maxLength={127}
|
||||
bind:value={createRecipientValues.department}
|
||||
placeholder="Sales"
|
||||
optional>Department</TextField
|
||||
>
|
||||
<TextField
|
||||
minLength={1}
|
||||
maxLength={127}
|
||||
bind:value={createRecipientValues.city}
|
||||
optional
|
||||
placeholder="Copenhagen">City</TextField
|
||||
>
|
||||
<TextField
|
||||
minLength={1}
|
||||
maxLength={127}
|
||||
bind:value={createRecipientValues.country}
|
||||
optional
|
||||
placeholder="Denmark">Country</TextField
|
||||
>
|
||||
<TextField
|
||||
minLength={1}
|
||||
maxLength={127}
|
||||
bind:value={createRecipientValues.misc}
|
||||
optional
|
||||
placeholder="VIP"
|
||||
toolTipText="Any extra information">Miscallaneous</TextField
|
||||
>
|
||||
</FormColumn>
|
||||
</FormColumns>
|
||||
<FormError message={createRecipientError} />
|
||||
<FormFooter closeModal={closeCreateRecipientModal} {isSubmitting} />
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
<Modal
|
||||
headerText={'Add recipients'}
|
||||
visible={isAddRecipientModalVisible}
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
<HeadTitle title="Orphaned Recipients" />
|
||||
|
||||
<section>
|
||||
<Headline>Orphaned Recipients</Headline>
|
||||
<Headline docSlug="recipients">Orphaned Recipients</Headline>
|
||||
|
||||
<div class="flex gap-4 mb-4">
|
||||
<BigButton on:click={() => goto('/recipient/')}>View recipients</BigButton>
|
||||
|
||||
@@ -285,7 +285,7 @@ s.keepAlive();
|
||||
<div class="col-start-1 col-end-13 row-start-1 px-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<Headline>Remote Browsers</Headline>
|
||||
<Headline docSlug="remote-browser">Remote Browsers</Headline>
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs font-medium rounded bg-slate-200 text-slate-500 dark:bg-slate-700 dark:text-slate-400"
|
||||
>Experimental</span
|
||||
@@ -306,7 +306,7 @@ s.keepAlive();
|
||||
To enable it, set <code class="text-slate-200 bg-slate-700 px-1 rounded">enabled: true</code
|
||||
>
|
||||
in the <code class="text-slate-200 bg-slate-700 px-1 rounded">remote_browser</code> block of
|
||||
<code class="text-slate-200 bg-slate-700 px-1 rounded">config.json</code> and retart the service.
|
||||
<code class="text-slate-200 bg-slate-700 px-1 rounded">config.json</code> and restart the service.
|
||||
</p>
|
||||
<a
|
||||
href="https://phishing.club/guide/remote-browser/#enabling"
|
||||
|
||||
@@ -267,14 +267,14 @@
|
||||
|
||||
<HeadTitle title="Scripts" />
|
||||
<main>
|
||||
<Headline>Scripts</Headline>
|
||||
<Headline docSlug="scripts">Scripts</Headline>
|
||||
|
||||
{#if featureDisabled}
|
||||
<div class="mt-6 max-w-xl rounded-lg border border-slate-700 bg-slate-800/50 px-6 py-8">
|
||||
<p class="mb-1 text-sm font-semibold text-white">Scripts are not enabled</p>
|
||||
<p class="mb-3 text-sm text-slate-400">
|
||||
This feature is disabled by default for security reasons. When enabled, any operator with
|
||||
access can write scripts that run on the server when campaign events fire — including
|
||||
access can write scripts that run on the server when campaign events fire, including
|
||||
making outbound HTTP requests. Only enable it on instances where every operator is trusted
|
||||
as a server admin.
|
||||
</p>
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
|
||||
<HeadTitle title="Sessions" />
|
||||
<main>
|
||||
<Headline>Sessions</Headline>
|
||||
<Headline docSlug="settings">Sessions</Headline>
|
||||
<BigButton on:click={openDeleteAllSessionsAlert}>Delete all sessions</BigButton>
|
||||
<Table
|
||||
columns={[
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
|
||||
<HeadTitle title="Settings" />
|
||||
<main class="pb-8">
|
||||
<Headline>Settings</Headline>
|
||||
<Headline docSlug="settings">Settings</Headline>
|
||||
|
||||
<nav class="mt-4 mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
|
||||
@@ -429,7 +429,7 @@
|
||||
{#each importResult.assets_errors_list as err}
|
||||
<li>
|
||||
<strong>{err.type}:</strong>
|
||||
{err.name} — {err.message}
|
||||
{err.name}: {err.message}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -477,7 +477,7 @@
|
||||
{#each importResult.pages_errors_list as err}
|
||||
<li>
|
||||
<strong>{err.type}:</strong>
|
||||
{err.name} — {err.message}
|
||||
{err.name}: {err.message}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -518,7 +518,7 @@
|
||||
{#each importResult.emails_errors_list as err}
|
||||
<li>
|
||||
<strong>{err.type}:</strong>
|
||||
{err.name} — {err.message}
|
||||
{err.name}: {err.message}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -537,7 +537,7 @@
|
||||
{#each importResult.errors as err}
|
||||
<li>
|
||||
<strong>{err.type}:</strong>
|
||||
{err.name} — {err.message}
|
||||
{err.name}: {err.message}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
|
||||
async function refreshScimDomain() {
|
||||
try {
|
||||
// only normal global domains may serve SCIM — exclude AiTM proxy domains
|
||||
// only normal global domains may serve SCIM, exclude AiTM proxy domains
|
||||
const [current, domains] = await Promise.all([
|
||||
api.option.getScimDomain(),
|
||||
api.domain.getAllSubsetWithoutProxies({ perPage: 1000 }, null)
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
|
||||
<HeadTitle title="Update - Settings" />
|
||||
<main class="pb-8">
|
||||
<Headline>Update</Headline>
|
||||
<Headline docSlug="management">Update</Headline>
|
||||
<div class="pt-4">
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-sm dark:shadow-gray-900/50 border border-gray-100 dark:border-gray-700 transition-colors duration-200"
|
||||
|
||||
@@ -451,7 +451,7 @@
|
||||
|
||||
<HeadTitle title="SMTP configurations" />
|
||||
<main>
|
||||
<Headline>SMTP Configurations</Headline>
|
||||
<Headline docSlug="smtp-configurations">SMTP Configurations</Headline>
|
||||
<BigButton on:click={openCreateModal}>New configuration</BigButton>
|
||||
<BulkActionBar
|
||||
count={$selection.size}
|
||||
|
||||
@@ -242,7 +242,7 @@
|
||||
|
||||
<HeadTitle title="Users" />
|
||||
<main>
|
||||
<Headline>Users</Headline>
|
||||
<Headline docSlug="settings">Users</Headline>
|
||||
<BigButton on:click={openCreateModal}>New user</BigButton>
|
||||
|
||||
<Table
|
||||
|
||||
@@ -306,7 +306,7 @@
|
||||
|
||||
<HeadTitle title="Webhooks" />
|
||||
<main>
|
||||
<Headline>Webhooks</Headline>
|
||||
<Headline docSlug="webhooks">Webhooks</Headline>
|
||||
<BigButton on:click={openCreateModal}>New webhook</BigButton>
|
||||
<BulkActionBar
|
||||
count={$selection.size}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Runs before the app renders to prevent a white flash in dark mode. Kept as a
|
||||
// file served from the app origin, not an inline script, so it is allowed by the
|
||||
// Content Security Policy script-src 'self' without an inline hash. Loaded as a
|
||||
// classic blocking script in the head so it runs before first paint.
|
||||
(function () {
|
||||
try {
|
||||
const storedTheme = localStorage.getItem('theme-mode');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const isDark = storedTheme === 'dark' || (!storedTheme && prefersDark);
|
||||
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.style.backgroundColor = '#111827';
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore errors
|
||||
}
|
||||
})();
|
||||
+23
-13
@@ -13,20 +13,30 @@ export default {
|
||||
alias: {
|
||||
$lib: './src/lib',
|
||||
'$lib/*': './src/lib/*'
|
||||
}
|
||||
// magic for dynamic routes to render at build time
|
||||
// creates one page for each word in the dictionary
|
||||
/*
|
||||
prerender: {
|
||||
crawl: true,
|
||||
entries: ['/']
|
||||
},
|
||||
*/
|
||||
/* TODO
|
||||
// Hash mode lets SvelteKit add the hash of its own inline start script, so
|
||||
// the app boots under script-src 'self' with no unsafe-inline. style-src
|
||||
// keeps unsafe-inline because Monaco and Svelte inject styles at runtime
|
||||
// that can not be hashed. blob: and data: are allowed only where previews
|
||||
// and Monaco workers need them, never for script. frame-ancestors and
|
||||
// form-action can not be set from a meta tag, so they live in the Go
|
||||
// security headers middleware instead.
|
||||
csp: {
|
||||
mode: "hash",
|
||||
directives: { "script-src": ["self"] },
|
||||
},
|
||||
*/
|
||||
mode: 'hash',
|
||||
directives: {
|
||||
'default-src': ['self'],
|
||||
'script-src': ['self'],
|
||||
'style-src': ['self', 'unsafe-inline'],
|
||||
'img-src': ['self', 'data:', 'blob:'],
|
||||
'font-src': ['self', 'data:'],
|
||||
'connect-src': ['self'],
|
||||
'worker-src': ['self', 'blob:'],
|
||||
// previews load from the same origin preview endpoint, not data: or
|
||||
// blob: documents, so frame-src only needs self
|
||||
'frame-src': ['self'],
|
||||
'object-src': ['none'],
|
||||
'base-uri': ['self']
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user