Proxy MITM

This commit is contained in:
Ronni Skansing
2025-09-22 09:13:04 +02:00
parent 7d0202858b
commit fc0a14303c
48 changed files with 8469 additions and 383 deletions
+5
View File
@@ -23,6 +23,8 @@ For a manual step by step guide or more in depth installation information - [cli
## Features
- **Multi-stage phishing flows** - Put together multiple phishing pages
- **Reverse proxy phishing** - Capture sessions to bypass weak MFA
- **Domain proxying** - Configure domains to proxy and mirror content from target sites
- **Flexible scheduling** - Time windows, business hours, or manual delivery
- **Multiple domains** - Auto TLS, custom sites, asset management
- **Advanced delivery** - SMTP configs or custom API endpoints
@@ -117,9 +119,12 @@ Visit the [Phishing Club Guide](https://phishing.club/guide/introduction/) for m
| 8102 | Mail Server | Mailpit SMTP server with SpamAssassin integration |
| 8103 | Container Logs | Dozzle log viewer |
| 8104 | Container Stats | Docker container statistics |
| 8105 | MITMProxy| MITMProxy web interface |
| 8106 | MITMProxy | MITMProxy external access |
| 8201 | ACME Server | Pebble ACME server for certificates |
| 8202 | ACME Management | Pebble management interface |
## Development Commands
The `makefile` has a lot of convenience commands for development.
+64
View File
@@ -0,0 +1,64 @@
# Third-Party Licenses
This file includes licenses from projects that are not dependencies but is included in some modified way.
This project incorporates code from third-party sources under different licenses. While the overall project is licensed under AGPL-3.0, the following components retain their original licenses:
## EvilGinx2
**Source**: https://github.com/kgretzky/evilginx2
**License**: BSD-3-Clause
**Copyright**: Copyright (c) 2017-2023 Kuba Gretzky (@kgretzky)
**Usage**: Portions of the HTTP proxy functionality in `backend/proxy/proxy.go` are derived from EvilGinx2
### BSD-3-Clause License Text
```
Copyright (c) 2017-2023 Kuba Gretzky (@kgretzky)
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
## Bettercap
**Source**: https://github.com/bettercap/bettercap
**License**: GPL-3.0
**Copyright**: Copyright (c) 2016-2023 Simone Margaritelli (@evilsocket)
**Usage**: Portions of the HTTP proxy functionality (via EvilGinx2) are derived from Bettercap
Note: EvilGinx2 itself incorporates and acknowledges code from the Bettercap project. Our usage maintains this attribution chain.
---
## License Compatibility
This project combines code under different licenses:
- **Overall Project**: AGPL-3.0 (see main LICENSE file)
- **BSD-3-Clause Components**: Compatible with AGPL-3.0, incorporated with proper attribution
- **GPL-3.0 Components**: Compatible with AGPL-3.0 through inheritance chain
All components are properly attributed and their usage complies with their respective license terms.
+11
View File
@@ -88,6 +88,10 @@ const (
ROUTE_V1_PAGE_OVERVIEW = "/api/v1/page/overview"
ROUTE_V1_PAGE_ID = "/api/v1/page/:id"
ROUTE_V1_PAGE_CONTENT_ID = "/api/v1/page/:id/content"
// proxy
ROUTE_V1_PROXY = "/api/v1/proxy"
ROUTE_V1_PROXY_OVERVIEW = "/api/v1/proxy/overview"
ROUTE_V1_PROXY_ID = "/api/v1/proxy/:id"
// recipient and groups
ROUTE_V1_RECIPIENT = "/api/v1/recipient"
ROUTE_V1_RECIPIENT_IMPORT = "/api/v1/recipient/import"
@@ -320,6 +324,13 @@ func setupRoutes(
POST(ROUTE_V1_PAGE, middleware.SessionHandler, controllers.Page.Create).
PATCH(ROUTE_V1_PAGE_ID, middleware.SessionHandler, controllers.Page.UpdateByID).
DELETE(ROUTE_V1_PAGE_ID, middleware.SessionHandler, controllers.Page.DeleteByID).
// proxy
GET(ROUTE_V1_PROXY, middleware.SessionHandler, controllers.Proxy.GetAll).
GET(ROUTE_V1_PROXY_OVERVIEW, middleware.SessionHandler, controllers.Proxy.GetOverview).
GET(ROUTE_V1_PROXY_ID, middleware.SessionHandler, controllers.Proxy.GetByID).
POST(ROUTE_V1_PROXY, middleware.SessionHandler, controllers.Proxy.Create).
PATCH(ROUTE_V1_PROXY_ID, middleware.SessionHandler, controllers.Proxy.UpdateByID).
DELETE(ROUTE_V1_PROXY_ID, middleware.SessionHandler, controllers.Proxy.DeleteByID).
// smtp configuration
GET(ROUTE_V1_SMTP_CONFIGURATION, middleware.SessionHandler, controllers.SMTPConfiguration.GetAll).
GET(ROUTE_V1_SMTP_CONFIGURATION_ID, middleware.SessionHandler, controllers.SMTPConfiguration.GetByID).
+6
View File
@@ -15,6 +15,7 @@ type Controllers struct {
Installer *controller.Install
InitialSetup *controller.InitialSetup
Page *controller.Page
Proxy *controller.Proxy
Log *controller.Log
Option *controller.Option
User *controller.User
@@ -101,6 +102,10 @@ func NewControllers(
PageService: services.Page,
TemplateService: services.Template,
}
proxy := &controller.Proxy{
Common: common,
ProxyService: services.Proxy,
}
option := &controller.Option{
Common: common,
OptionService: services.Option,
@@ -182,6 +187,7 @@ func NewControllers(
InitialSetup: initialSetup,
Health: health,
Page: page,
Proxy: proxy,
Log: log,
Option: option,
User: user,
+2
View File
@@ -12,6 +12,7 @@ type Repositories struct {
Company *repository.Company
Option *repository.Option
Page *repository.Page
Proxy *repository.Proxy
Role *repository.Role
Session *repository.Session
User *repository.User
@@ -40,6 +41,7 @@ func NewRepositories(
Company: &repository.Company{DB: db},
Option: option,
Page: &repository.Page{DB: db},
Proxy: &repository.Proxy{DB: db},
Role: &repository.Role{DB: db},
Session: &repository.Session{DB: db},
User: &repository.User{DB: db},
+461 -69
View File
@@ -9,6 +9,7 @@ import (
"mime"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@@ -16,6 +17,7 @@ import (
"time"
"github.com/go-errors/errors"
"gopkg.in/yaml.v3"
"github.com/caddyserver/certmagic"
securejoin "github.com/cyphar/filepath-securejoin"
@@ -27,6 +29,7 @@ import (
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/proxy"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/server"
"github.com/phishingclub/phishingclub/service"
@@ -50,6 +53,7 @@ type Server struct {
controllers *Controllers
services *Services
repositories *Repositories
proxyServer *proxy.ProxyHandler
}
// NewServer returns a new server
@@ -63,6 +67,38 @@ func NewServer(
logger *zap.SugaredLogger,
certMagicConfig *certmagic.Config,
) *Server {
// setup proxy cookie tracking
cookieName := ""
if option, err := repositories.Option.GetByKey(context.Background(), data.OptionKeyProxyCookieName); err == nil && option != nil {
cookieName = option.Value.String()
}
// setup goproxy-based proxy server
proxyServer := proxy.NewProxyHandler(
logger,
repositories.Page,
repositories.CampaignRecipient,
repositories.Campaign,
repositories.CampaignTemplate,
repositories.Domain,
repositories.Proxy,
repositories.Identifier,
services.Campaign,
cookieName,
)
// setup proxy session cleanup routine
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
proxyServer.CleanupExpiredSessions()
}
}
}()
return &Server{
staticPath: staticPath,
ownManagedTLSCertPath: ownManagedTLSCertPath,
@@ -72,6 +108,7 @@ func NewServer(
repositories: repositories,
logger: logger,
certMagicConfig: certMagicConfig,
proxyServer: proxyServer,
}
}
@@ -233,21 +270,36 @@ func (s *Server) checkAndServeSharedAsset(c *gin.Context) bool {
// checks if the request should be redirected
// checks if the request is for a static page or static not found page
func (s *Server) Handler(c *gin.Context) {
// add error recovery for handler
defer func() {
if r := recover(); r != nil {
s.logger.Errorw("panic in handler",
"panic", r,
"host", c.Request.Host,
"url", c.Request.URL.String(),
)
c.Status(http.StatusInternalServerError)
c.Abort()
}
}()
host, err := s.getHostOnly(c.Request.Host)
if err != nil {
s.logger.Debugw("failed to parse host",
"rawHost", c.Request.Host,
"error", err,
)
c.Status(http.StatusNotFound)
c.Abort()
return
}
// check if the domain is valid
// use DB directly here to avoid getting unnecessary data
// as a domain contains big blobs for static content
var domain *database.Domain
res := s.db.
Select("id, name, host_website, redirect_url").
Select("id, name, type, proxy_id, proxy_target_domain, host_website, redirect_url").
Where("name = ?", host).
First(&domain)
@@ -257,6 +309,26 @@ func (s *Server) Handler(c *gin.Context) {
c.Abort()
return
}
// check if this is a proxy domain - if so, handle it with proxy server
if domain.Type == "proxy" {
s.logger.Debugw("handling proxy domain request",
"host", host,
"targetDomain", domain.ProxyTargetDomain,
"path", c.Request.URL.Path,
)
err = s.proxyServer.HandleHTTPRequest(c.Writer, c.Request, domain)
if err != nil {
s.logger.Errorw("failed to handle proxy request",
"error", err,
"host", host,
)
c.Status(http.StatusInternalServerError)
}
c.Abort()
return
}
// check if the request is for a tacking pixel
if c.Request.URL.Path == "/wf/open" {
s.controllers.Campaign.TrackingPixel(c)
@@ -265,6 +337,8 @@ func (s *Server) Handler(c *gin.Context) {
}
// check if the request is for a phishing page or is denied by allow/deny list
// this must come BEFORE proxy cookie check to ensure initial requests with campaign recipient IDs
// are treated as initial requests even if they have existing proxy cookies
isRequestForPhishingPageOrDenied, err := s.checkAndServePhishingPage(c, domain)
if err != nil {
s.logger.Errorw("failed to serve phishing page",
@@ -278,6 +352,20 @@ func (s *Server) Handler(c *gin.Context) {
if isRequestForPhishingPageOrDenied {
return
}
// check for proxy cookie - only if this wasn't a phishing page request
// this ensures that requests with campaign recipient IDs are handled as initial requests
if s.proxyServer.IsValidProxyCookie(s.getProxyCookieValue(c)) {
err = s.proxyServer.HandleHTTPRequest(c.Writer, c.Request, domain)
if err != nil {
s.logger.Errorw("failed to handle proxy request",
"error", err,
)
c.Status(http.StatusInternalServerError)
}
c.Abort()
return
}
// check if the request is for assets
servedAssets := s.checkAndServeAssets(c, host)
if servedAssets {
@@ -458,65 +546,26 @@ func (s *Server) checkAndServePhishingPage(
c *gin.Context,
domain *database.Domain,
) (bool, error) {
// get all identifiers and collect all that match query params
identifiers, err := s.repositories.Identifier.GetAll(c, &repository.IdentifierOption{})
// get campaign recipient from URL parameters
campaignRecipient, _, err := server.GetCampaignRecipientFromURLParams(
c,
c.Request,
s.repositories.Identifier,
s.repositories.CampaignRecipient,
)
if err != nil {
s.logger.Debugw("failed to get all identifiers",
s.logger.Debugw("failed to get campaign recipient from URL parameters",
"error", err,
)
return false, errs.Wrap(err)
}
query := c.Request.URL.Query()
matchingParams := []string{}
for _, identifier := range identifiers.Rows {
if name := identifier.Name.MustGet(); query.Has(name) {
matchingParams = append(matchingParams, name)
}
}
// check which match a UUIDv4 and check if any of those match a campaignrecipient id
matchingUUIDParams := []*uuid.UUID{}
for _, param := range matchingParams {
if id, err := uuid.Parse(query.Get(param)); err == nil {
matchingUUIDParams = append(matchingUUIDParams, &id)
}
}
if len(matchingUUIDParams) == 0 {
s.logger.Debugw("'campaignrecipient' not found",
"error", err,
)
return false, nil
}
var campaignRecipient *model.CampaignRecipient
var campaignRecipientID *uuid.UUID
// however limit it to 3 attempts to prevent a DoS attack
for i, v := range matchingUUIDParams {
if i > 2 {
s.logger.Warn("too many attempts to get campaign recipient by a UUID. Ensure that there are no more than max 3 UUID in the phishing URL!")
return false, nil
}
campaignRecipient, err = s.repositories.CampaignRecipient.GetByCampaignRecipientID(
c,
v,
)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.logger.Debugw("failed to get active campaign and campaign recipient by query param",
"error", err,
)
return false, fmt.Errorf("failed to get active campaign and campaign recipient by query param: %s", err)
}
if campaignRecipient != nil {
campaignRecipientID = v
break
}
}
// there was a campagin recipient id but it did not match a campaign
// this could be because there is an ID value but is not for us
if campaignRecipient == nil {
s.logger.Debugw("'campaignrecipient' not found",
"error", err,
)
s.logger.Debugw("'campaignrecipient' not found")
return false, nil
}
campaignRecipientID := campaignRecipient.ID.MustGet()
campaignRecipientIDPtr := &campaignRecipientID
// at this point we know which url param matched the campaignrecipientID, however
// it could have been any available identifier and not the one matching the campaign template
// it is possible now to check if it is correct, however it does not matter as the campaign
@@ -587,16 +636,29 @@ func (s *Server) checkAndServePhishingPage(
}
// figure out which page types this template has
var beforePageID *uuid.UUID
var beforeProxyID *uuid.UUID
if v, err := cTemplate.BeforeLandingPageID.Get(); err == nil {
beforePageID = &v
} else if v, err := cTemplate.BeforeLandingProxyID.Get(); err == nil {
beforeProxyID = &v
}
landingPageID, err := cTemplate.LandingPageID.Get()
if err != nil {
return false, fmt.Errorf("Template is incomplete, missing landing page ID: %s", err)
var landingPageID *uuid.UUID
var landingProxyID *uuid.UUID
if v, err := cTemplate.LandingPageID.Get(); err == nil {
landingPageID = &v
} else if v, err := cTemplate.LandingProxyID.Get(); err == nil {
landingProxyID = &v
} else {
return false, fmt.Errorf("Template is incomplete, missing landing page or Proxy ID")
}
var afterPageID *uuid.UUID
var afterProxyID *uuid.UUID
if v, err := cTemplate.AfterLandingPageID.Get(); err == nil {
afterPageID = &v
} else if v, err := cTemplate.AfterLandingProxyID.Get(); err == nil {
afterProxyID = &v
}
stateParamKey := cTemplate.StateIdentifier.Name.MustGet()
@@ -608,17 +670,50 @@ func (s *Server) checkAndServePhishingPage(
}
// if there is no page type then this is the before landing page or the landing page
var pageID *uuid.UUID
var proxyID *uuid.UUID
nextPageType := ""
currentPageType := ""
s.logger.Debugw("determining page flow",
"pageTypeQuery", pageTypeQuery,
"hasBeforePage", beforePageID != nil,
"hasBeforeProxy", beforeProxyID != nil,
"hasLandingPage", landingPageID != nil,
"hasLandingProxy", landingProxyID != nil,
"hasAfterPage", afterPageID != nil,
"hasAfterProxy", afterProxyID != nil,
"campaignRecipientID", campaignRecipientID.String(),
)
if len(pageTypeQuery) == 0 {
if beforePageID != nil {
pageID = beforePageID
if beforePageID != nil || beforeProxyID != nil {
if beforePageID != nil {
pageID = beforePageID
s.logger.Debugw("initial request - serving before landing page",
"pageID", pageID.String(),
)
} else {
proxyID = beforeProxyID
s.logger.Debugw("initial request - serving before landing Proxy",
"proxyID", proxyID.String(),
)
}
currentPageType = data.PAGE_TYPE_BEFORE
nextPageType = data.PAGE_TYPE_LANDING
} else {
pageID = &landingPageID
if landingPageID != nil {
pageID = landingPageID
s.logger.Debugw("initial request - serving landing page",
"pageID", pageID.String(),
)
} else {
proxyID = landingProxyID
s.logger.Debugw("initial request - serving landing Proxy",
"proxyID", proxyID.String(),
)
}
currentPageType = data.PAGE_TYPE_LANDING
if afterPageID != nil {
if afterPageID != nil || afterProxyID != nil {
nextPageType = data.PAGE_TYPE_AFTER
} else {
nextPageType = data.PAGE_TYPE_DONE // landing page is final page
@@ -631,28 +726,70 @@ func (s *Server) checkAndServePhishingPage(
case data.PAGE_TYPE_BEFORE:
// this is set if the previous page was a before page
case data.PAGE_TYPE_LANDING:
pageID = &landingPageID
if landingPageID != nil {
pageID = landingPageID
s.logger.Debugw("serving landing page from state",
"pageID", pageID.String(),
)
} else {
proxyID = landingProxyID
s.logger.Debugw("serving landing Proxy from state",
"proxyID", proxyID.String(),
)
}
currentPageType = data.PAGE_TYPE_LANDING
if afterPageID != nil {
if afterPageID != nil || afterProxyID != nil {
nextPageType = data.PAGE_TYPE_AFTER
} else {
nextPageType = data.PAGE_TYPE_DONE // landiung page is final page
nextPageType = data.PAGE_TYPE_DONE // landing page is final page
}
// this is set if the previous page was a landing page
case data.PAGE_TYPE_AFTER:
if afterPageID != nil {
pageID = afterPageID
s.logger.Debugw("serving after landing page from state",
"pageID", pageID.String(),
)
} else if afterProxyID != nil {
proxyID = afterProxyID
s.logger.Debugw("serving after landing Proxy from state",
"proxyID", proxyID.String(),
)
} else if landingPageID != nil {
pageID = landingPageID
s.logger.Debugw("fallback to landing page for after state",
"pageID", pageID.String(),
)
} else {
pageID = &landingPageID
proxyID = landingProxyID
s.logger.Debugw("fallback to landing Proxy for after state",
"proxyID", proxyID.String(),
)
}
// next page after a after landinge page, is the same page
// next page after a after landing page, is the same page
currentPageType = data.PAGE_TYPE_AFTER
nextPageType = data.PAGE_TYPE_DONE
case data.PAGE_TYPE_DONE:
if afterPageID != nil {
pageID = afterPageID
s.logger.Debugw("serving after landing page for done state",
"pageID", pageID.String(),
)
} else if afterProxyID != nil {
proxyID = afterProxyID
s.logger.Debugw("serving after landing Proxy for done state",
"proxyID", proxyID.String(),
)
} else if landingPageID != nil {
pageID = landingPageID
s.logger.Debugw("fallback to landing page for done state",
"pageID", pageID.String(),
)
} else {
pageID = &landingPageID
proxyID = landingProxyID
s.logger.Debugw("fallback to landing Proxy for done state",
"proxyID", proxyID.String(),
)
}
currentPageType = data.PAGE_TYPE_DONE
nextPageType = data.PAGE_TYPE_DONE
@@ -715,7 +852,7 @@ func (s *Server) checkAndServePhishingPage(
campaignRecipient.NotableEventID.Set(*submitDataEventID)
err := s.repositories.CampaignRecipient.UpdateByID(
c,
campaignRecipientID,
campaignRecipientIDPtr,
campaignRecipient,
)
if err != nil {
@@ -766,7 +903,252 @@ func (s *Server) checkAndServePhishingPage(
}
}
}
// fetch the page
// handle Proxy pages
if proxyID != nil {
// this is a Proxy page - redirect to the phishing domain
proxy, err := s.repositories.Proxy.GetByID(
c,
proxyID,
&repository.ProxyOption{},
)
if err != nil {
return true, fmt.Errorf("failed to get Proxy page: %s", err)
}
startURL, err := proxy.StartURL.Get()
if err != nil {
return true, fmt.Errorf("Proxy page has no start URL: %s", err)
}
// parse proxy config to find the phishing domain
proxyConfig, err := proxy.ProxyConfig.Get()
if err != nil {
return true, fmt.Errorf("Proxy page has no configuration: %s", err)
}
// extract the phishing domain from Proxy configuration
var rawConfig map[string]interface{}
err = yaml.Unmarshal([]byte(proxyConfig.String()), &rawConfig)
if err != nil {
return true, fmt.Errorf("invalid Proxy configuration YAML: %s", err)
}
// parse the start URL to get the target domain
parsedStartURL, err := url.Parse(startURL.String())
if err != nil {
return true, fmt.Errorf("invalid proxy start URL: %s", err)
}
startDomain := parsedStartURL.Host
// find the phishing domain mapping for the start URL domain
phishingDomain := ""
for originalHost, domainData := range rawConfig {
if originalHost == "proxy" || originalHost == "global" {
continue
}
if originalHost == startDomain {
if domainMap, ok := domainData.(map[string]interface{}); ok {
if to, exists := domainMap["to"]; exists {
if toStr, ok := to.(string); ok {
phishingDomain = toStr
break
}
}
}
}
}
if phishingDomain == "" {
return true, fmt.Errorf("no phishing domain mapping found for start URL domain: %s", startDomain)
}
// save the event of Proxy page being accessed
visitEventID := uuid.New()
eventName := ""
switch currentPageType {
case data.PAGE_TYPE_BEFORE:
eventName = data.EVENT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED
case data.PAGE_TYPE_LANDING:
eventName = data.EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED
case data.PAGE_TYPE_AFTER:
eventName = data.EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED
default:
eventName = data.EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED
}
eventID := cache.EventIDByName[eventName]
clientIP := vo.NewOptionalString64Must(c.ClientIP())
userAgent := vo.NewOptionalString255Must(utils.Substring(c.Request.UserAgent(), 0, MAX_USER_AGENT_SAVED))
var visitEvent *model.CampaignEvent
if !campaign.IsAnonymous.MustGet() {
visitEvent = &model.CampaignEvent{
ID: &visitEventID,
CampaignID: &campaignID,
RecipientID: &recipientID,
IP: clientIP,
UserAgent: userAgent,
EventID: eventID,
Data: vo.NewEmptyOptionalString1MB(),
}
} else {
ua := vo.NewEmptyOptionalString255()
visitEvent = &model.CampaignEvent{
ID: &visitEventID,
CampaignID: &campaignID,
RecipientID: nil,
IP: vo.NewEmptyOptionalString64(),
UserAgent: ua,
EventID: eventID,
Data: vo.NewEmptyOptionalString1MB(),
}
}
// save the visit event unless it's the final page repeat
if currentPageType != data.PAGE_TYPE_DONE {
err = s.repositories.Campaign.SaveEvent(
c,
visitEvent,
)
if err != nil {
s.logger.Errorw("failed to save proxy visit event",
"error", err,
"proxyID", proxyID.String(),
)
}
// check and update if most notable event for recipient
currentNotableEventID, _ := campaignRecipient.NotableEventID.Get()
if cache.IsMoreNotableCampaignRecipientEventID(
&currentNotableEventID,
eventID,
) {
campaignRecipient.NotableEventID.Set(*eventID)
err := s.repositories.CampaignRecipient.UpdateByID(
c,
campaignRecipientIDPtr,
campaignRecipient,
)
if err != nil {
s.logger.Errorw("failed to update notable event for proxy",
"campaignRecipientID", campaignRecipientID.String(),
"eventID", eventID.String(),
"error", err,
)
}
}
}
// handle webhook for Proxy page visit
webhookID, err := s.repositories.Campaign.GetWebhookIDByCampaignID(
c,
&campaignID,
)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.logger.Errorw("failed to get webhook id by campaign id for proxy",
"campaignID", campaignID.String(),
"error", err,
)
}
if webhookID != nil && currentPageType != data.PAGE_TYPE_DONE {
err = s.services.Campaign.HandleWebhook(
// TODO this should be tied to a application wide context not the request
context.TODO(),
webhookID,
&campaignID,
&recipientID,
eventName,
)
if err != nil {
s.logger.Errorw("failed to handle webhook for Proxy page",
"error", err,
"proxyID", proxyID.String(),
)
}
}
// validate phishing domain format
if strings.Contains(phishingDomain, "://") || strings.Contains(phishingDomain, "/") {
return true, fmt.Errorf("invalid phishing domain format: %s", phishingDomain)
}
// validate that the phishing domain is configured as a proxy domain
var phishingDomainRecord *database.Domain
res := s.db.
Select("id, name, type, proxy_id, proxy_target_domain").
Where("name = ?", phishingDomain).
First(&phishingDomainRecord)
if res.RowsAffected == 0 {
return true, fmt.Errorf("phishing domain '%s' is not configured in the system", phishingDomain)
}
if phishingDomainRecord.Type != "proxy" {
return true, fmt.Errorf("phishing domain '%s' is not configured as proxy type", phishingDomain)
}
s.logger.Debugw("redirecting to Proxy phishing domain",
"proxyID", proxyID.String(),
"startURL", startURL.String(),
"phishingDomain", phishingDomain,
"currentPageType", currentPageType,
"phishingDomainType", phishingDomainRecord.Type,
)
// build the redirect URL to the phishing domain with campaign recipient ID
urlParam := cTemplate.URLIdentifier.Name.MustGet()
// construct the redirect URL properly
u := &url.URL{
Scheme: "https",
Host: phishingDomain,
Path: parsedStartURL.Path,
}
q := u.Query()
q.Set(urlParam, campaignRecipientID.String())
if encryptedParam != "" {
q.Set(stateParamKey, encryptedParam)
}
// preserve any existing query params from start URL
if parsedStartURL.RawQuery != "" {
startQuery, _ := url.ParseQuery(parsedStartURL.RawQuery)
for key, values := range startQuery {
for _, value := range values {
q.Add(key, value)
}
}
}
u.RawQuery = q.Encode()
s.logger.Debugw("built proxy redirect URL",
"redirectURL", u.String(),
"phishingDomain", phishingDomain,
"originalPath", parsedStartURL.Path,
)
// validate the final URL
finalURL := u.String()
if !strings.HasPrefix(finalURL, "https://") {
return true, fmt.Errorf("invalid redirect URL scheme: %s", finalURL)
}
s.logger.Infow("redirecting to proxy domain",
"from", c.Request.Host+c.Request.URL.Path,
"to", finalURL,
"campaignRecipientID", campaignRecipientID.String(),
)
c.Redirect(http.StatusSeeOther, finalURL)
c.Abort()
return true, nil
}
// ensure we have a page ID if we're not handling a proxy
if pageID == nil {
return true, fmt.Errorf("no page or proxy configured for current step")
}
// fetch the regular page
page, err := s.repositories.Page.GetByID(
c,
pageID,
@@ -775,6 +1157,7 @@ func (s *Server) checkAndServePhishingPage(
if err != nil {
return true, fmt.Errorf("failed to get landing page: %s", err)
}
// fetch the sender email to use for the template
emailID := cTemplate.EmailID.MustGet()
email, err := s.repositories.Email.GetByID(
@@ -794,7 +1177,7 @@ func (s *Server) checkAndServePhishingPage(
c,
domain,
email,
campaignRecipientID,
campaignRecipientIDPtr,
recipient,
page,
cTemplate,
@@ -860,7 +1243,7 @@ func (s *Server) checkAndServePhishingPage(
campaignRecipient.NotableEventID.Set(*eventID)
err := s.repositories.CampaignRecipient.UpdateByID(
c,
campaignRecipientID,
campaignRecipientIDPtr,
campaignRecipient,
)
if err != nil {
@@ -950,6 +1333,15 @@ func (s *Server) AssignRoutes(r *gin.Engine) {
r.NoRoute(s.handlerNotFound)
}
// getProxyCookieValue extracts proxy cookie value from gin context
func (s *Server) getProxyCookieValue(c *gin.Context) string {
cookieName := s.proxyServer.GetCookieName()
if cookieValue, err := c.Cookie(cookieName); err == nil {
return cookieValue
}
return ""
}
func (s *Server) StartHTTP(
r *gin.Engine,
conf *config.Config,
+11
View File
@@ -16,6 +16,7 @@ type Services struct {
InstallSetup *service.InstallSetup
Option *service.Option
Page *service.Page
Proxy *service.Proxy
Session *service.Session
User *service.User
Domain *service.Domain
@@ -141,6 +142,7 @@ func NewServices(
PageRepository: repositories.Page,
CampaignTemplateService: campaignTemplate,
TemplateService: templateService,
DomainRepository: repositories.Domain,
}
domain := &service.Domain{
Common: common,
@@ -154,6 +156,14 @@ func NewServices(
FileService: file,
TemplateService: templateService,
}
proxy := &service.Proxy{
Common: common,
ProxyRepository: repositories.Proxy,
DomainRepository: repositories.Domain,
CampaignRepository: repositories.Campaign,
CampaignTemplateService: campaignTemplate,
DomainService: domain,
}
email := &service.Email{
Common: common,
AttachmentPath: attachmentPath,
@@ -242,6 +252,7 @@ func NewServices(
InstallSetup: installSetup,
Option: optionService,
Page: page,
Proxy: proxy,
Session: sessionService,
User: userService,
Domain: domain,
+24 -18
View File
@@ -75,14 +75,17 @@ func (c *CampaignTemplate) GetByID(g *gin.Context) {
_, ok = g.GetQuery("full")
if ok {
options = &repository.CampaignTemplateOption{
WithDomain: true,
WithSMTPConfiguration: true,
WithAPISender: true,
WithEmail: true,
WithLandingPage: true,
WithBeforeLandingPage: true,
WithAfterLandingPage: true,
WithIdentifier: true,
WithDomain: true,
WithSMTPConfiguration: true,
WithAPISender: true,
WithEmail: true,
WithLandingPage: true,
WithBeforeLandingPage: true,
WithAfterLandingPage: true,
WithLandingProxy: true,
WithBeforeLandingProxy: true,
WithAfterLandingProxy: true,
WithIdentifier: true,
}
}
// get
@@ -130,16 +133,19 @@ func (c *CampaignTemplate) GetAll(g *gin.Context) {
companyID,
pagination,
&repository.CampaignTemplateOption{
QueryArgs: queryArgs,
Columns: columns,
WithDomain: true,
WithSMTPConfiguration: true,
WithAPISender: true,
WithEmail: true,
WithLandingPage: true,
WithBeforeLandingPage: true,
WithAfterLandingPage: true,
UsableOnly: usableOnly,
QueryArgs: queryArgs,
Columns: columns,
WithDomain: true,
WithSMTPConfiguration: true,
WithAPISender: true,
WithEmail: true,
WithLandingPage: true,
WithBeforeLandingPage: true,
WithAfterLandingPage: true,
WithLandingProxy: true,
WithBeforeLandingProxy: true,
WithAfterLandingProxy: true,
UsableOnly: usableOnly,
},
)
// handle response
+194
View File
@@ -0,0 +1,194 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/service"
)
// ProxyColumnsMap is a map between the frontend and the backend
// so the frontend has user friendly names instead of direct references
// to the database schema
// this is tied to a slice in the repository package
var ProxyColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.PROXY_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.PROXY_TABLE, "updated_at"),
"name": repository.TableColumn(database.PROXY_TABLE, "name"),
"target_domain": repository.TableColumn(database.PROXY_TABLE, "target_domain"),
}
// Proxy is a proxy controller
type Proxy struct {
Common
ProxyService *service.Proxy
}
// Create creates a proxy
func (m *Proxy) Create(g *gin.Context) {
// handle session
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse req
var req model.Proxy
if ok := m.handleParseRequest(g, &req); !ok {
return
}
// save proxy
id, err := m.ProxyService.Create(
g.Request.Context(),
session,
&req,
)
// handle response
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, map[string]string{
"id": id.String(),
})
}
// GetOverview gets proxies overview using pagination
func (m *Proxy) GetOverview(g *gin.Context) {
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := m.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
companyID := companyIDFromRequestQuery(g)
// get proxies
proxies, err := m.ProxyService.GetAllOverview(
companyID,
g,
session,
queryArgs,
)
// handle response
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, proxies)
}
// GetAll gets all proxies using pagination
func (m *Proxy) GetAll(g *gin.Context) {
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse request
queryArgs, ok := m.handleQueryArgs(g)
if !ok {
return
}
queryArgs.DefaultSortByUpdatedAt()
companyID := companyIDFromRequestQuery(g)
// get proxies
proxies, err := m.ProxyService.GetAll(
g,
session,
companyID,
&repository.ProxyOption{
QueryArgs: queryArgs,
},
)
// handle response
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, proxies)
}
// GetByID gets a proxy by ID
func (m *Proxy) GetByID(g *gin.Context) {
// handle session
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse request
id, ok := m.handleParseIDParam(g)
if !ok {
return
}
// get proxy
proxy, err := m.ProxyService.GetByID(
g.Request.Context(),
session,
id,
&repository.ProxyOption{},
)
// handle response
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, proxy)
}
// UpdateByID updates a proxy by ID
func (m *Proxy) UpdateByID(g *gin.Context) {
// handle session
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse request
id, ok := m.handleParseIDParam(g)
if !ok {
return
}
var req model.Proxy
if ok := m.handleParseRequest(g, &req); !ok {
return
}
// update proxy
err := m.ProxyService.UpdateByID(
g.Request.Context(),
session,
id,
&req,
)
// handle response
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, map[string]string{
"message": "Proxy updated",
})
}
// DeleteByID deletes a proxy by ID
func (m *Proxy) DeleteByID(g *gin.Context) {
// handle session
session, _, ok := m.handleSession(g)
if !ok {
return
}
// parse request
id, ok := m.handleParseIDParam(g)
if !ok {
return
}
// delete proxy
err := m.ProxyService.DeleteByID(
g.Request.Context(),
session,
id,
)
// handle response
if ok := m.handleErrors(g, err); !ok {
return
}
m.Response.OK(g, map[string]string{
"message": "Proxy deleted",
})
}
+2
View File
@@ -23,4 +23,6 @@ const (
OptionKeyRepeatOffenderMonths = "repeat_offender_months"
OptionKeyAdminSSOLogin = "sso_login"
OptionKeyProxyCookieName = "proxy_cookie_name"
)
+12
View File
@@ -29,6 +29,10 @@ type CampaignTemplate struct {
LandingPageID *uuid.UUID `gorm:"type:uuid;index;"`
LandingPage *Page `gorm:"references:LandingPage;foreignKey:LandingPageID;references:ID;"`
// landing page can also be a proxy
LandingProxyID *uuid.UUID `gorm:"type:uuid;index;"`
LandingProxy *Proxy `gorm:"foreignKey:LandingProxyID;references:ID;"`
DomainID *uuid.UUID `gorm:"type:uuid;index;"`
Domain *Domain `gorm:"foreignKey:DomainID"`
@@ -42,9 +46,17 @@ type CampaignTemplate struct {
BeforeLandingPageID *uuid.UUID `gorm:"type:uuid;index"`
BeforeLandingPage *Page `gorm:"foreignkey:BeforeLandingPageID;references:ID"`
// before landing page can also be a proxy
BeforeLandingProxyID *uuid.UUID `gorm:"type:uuid;index"`
BeforeLandingProxy *Proxy `gorm:"foreignKey:BeforeLandingProxyID;references:ID"`
AfterLandingPageID *uuid.UUID `gorm:"type:uuid;index"`
AfterLandingPage *Page `gorm:"foreignKey:AfterLandingPageID;references:ID"`
// after landing page can also be a proxy
AfterLandingProxyID *uuid.UUID `gorm:"type:uuid;index"`
AfterLandingProxy *Proxy `gorm:"foreignKey:AfterLandingProxyID;references:ID"`
AfterLandingPageRedirectURL string `gorm:"not null;"`
EmailID *uuid.UUID `gorm:"type:uuid;index;"`
+12 -8
View File
@@ -12,14 +12,18 @@ const (
// Domain is gorm data model
type Domain struct {
ID uuid.UUID `gorm:"primary_key;not null;unique;type:uuid;"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index;"`
CompanyID *uuid.UUID `gorm:"index;type:uuid;"`
Name string `gorm:"not null;unique;"`
ManagedTLSCerts bool `gorm:"not null;index;default:false"`
OwnManagedTLS bool `gorm:"not null;index;default:false"`
HostWebsite bool `gorm:"not null;"`
ID uuid.UUID `gorm:"primary_key;not null;unique;type:uuid;"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index;"`
CompanyID *uuid.UUID `gorm:"index;type:uuid;"`
ProxyID *uuid.UUID `gorm:"index;type:uuid;"`
Name string `gorm:"not null;unique;"`
Type string `gorm:"not null;default:'regular';"`
ProxyTargetDomain string
ManagedTLSCerts bool `gorm:"not null;index;default:false"`
OwnManagedTLS bool `gorm:"not null;index;default:false"`
HostWebsite bool `gorm:"not null;"`
PageContent string
PageNotFoundContent string
RedirectURL string
+9 -6
View File
@@ -13,12 +13,15 @@ const (
// Page is a gorm data model
type Page struct {
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index"`
CompanyID *uuid.UUID `gorm:"index;uniqueIndex:idx_pages_unique_name_and_company_id;type:uuid"`
Name string `gorm:"not null;index;uniqueIndex:idx_pages_unique_name_and_company_id;"`
Content string `gorm:"not null;"`
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index"`
CompanyID *uuid.UUID `gorm:"index;uniqueIndex:idx_pages_unique_name_and_company_id;type:uuid"`
Name string `gorm:"not null;index;uniqueIndex:idx_pages_unique_name_and_company_id;"`
Content string `gorm:"not null;"`
Type string `gorm:"not null;default:'regular';"`
TargetURL string
ProxyConfig string
// could has-one
Company *Company
+37
View File
@@ -0,0 +1,37 @@
package database
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
PROXY_TABLE = "proxies"
)
// Proxy is a gorm data model
type Proxy struct {
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index"`
CompanyID *uuid.UUID `gorm:"index;uniqueIndex:idx_proxies_unique_name_and_company_id;type:uuid"`
Name string `gorm:"not null;index;uniqueIndex:idx_proxies_unique_name_and_company_id;"`
Description string `gorm:"type:text"`
StartURL string `gorm:"not null;"`
ProxyConfig string `gorm:"type:text;not null;"`
// could has-one
Company *Company
}
func (e *Proxy) Migrate(db *gorm.DB) error {
// SQLITE
// ensure name + company id is unique
return UniqueIndexNameAndNullCompanyID(db, "proxies")
}
func (Proxy) TableName() string {
return PROXY_TABLE
}
+1 -1
View File
@@ -130,7 +130,7 @@ func main() {
*flagConfigPath,
)
if err != nil {
golog.Fatalf("failed to config: %s", err)
golog.Fatalf("failed to setup config: %s", err)
}
// setup database connection
db, err := app.SetupDatabase(conf)
+76 -1
View File
@@ -26,12 +26,24 @@ type CampaignTemplate struct {
BeforeLandingPageID nullable.Nullable[uuid.UUID] `json:"beforeLandingPageID"`
BeforeLandingePage *Page `json:"beforeLandingPage"`
// before landing page can also be a proxy
BeforeLandingProxyID nullable.Nullable[uuid.UUID] `json:"beforeLandingProxyID"`
BeforeLandingProxy *Proxy `json:"beforeLandingProxy"`
LandingPageID nullable.Nullable[uuid.UUID] `json:"landingPageID"`
LandingPage *Page `json:"landingPage"`
// landing page can also be a proxy
LandingProxyID nullable.Nullable[uuid.UUID] `json:"landingProxyID"`
LandingProxy *Proxy `json:"landingProxy"`
AfterLandingPageID nullable.Nullable[uuid.UUID] `json:"afterLandingPageID"`
AfterLandingPage *Page `json:"afterLandingPage"`
// after landing page can also be a proxy
AfterLandingProxyID nullable.Nullable[uuid.UUID] `json:"afterLandingProxyID"`
AfterLandingProxy *Proxy `json:"afterLandingProxy"`
AfterLandingPageRedirectURL nullable.Nullable[vo.OptionalString255] `json:"afterLandingPageRedirectURL"`
URLIdentifierID nullable.Nullable[*uuid.UUID] `json:"urlIdentifierID"`
@@ -80,6 +92,41 @@ func (c *CampaignTemplate) Validate() error {
if err := validate.NullableFieldRequired("urlPath", c.URLPath); err != nil {
return err
}
// validate that only one type is set per stage
// before landing page: can have neither (optional), or one type, but not both
_, errBeforePage := c.BeforeLandingPageID.Get()
_, errBeforeProxy := c.BeforeLandingProxyID.Get()
if errBeforePage == nil && errBeforeProxy == nil {
return errs.NewValidationError(
errors.New("before landing page cannot be both a page and a proxy"),
)
}
// landing page: must have exactly one type (required)
_, errLandingPage := c.LandingPageID.Get()
_, errLandingProxy := c.LandingProxyID.Get()
if errLandingPage == nil && errLandingProxy == nil {
return errs.NewValidationError(
errors.New("landing page cannot be both a page and a proxy"),
)
}
if errLandingPage != nil && errLandingProxy != nil {
return errs.NewValidationError(
errors.New("landing page is required (must be either a page or a proxy)"),
)
}
// after landing page: can have neither (optional), or one type, but not both
_, errAfterPage := c.AfterLandingPageID.Get()
_, errAfterProxy := c.AfterLandingProxyID.Get()
if errAfterPage == nil && errAfterProxy == nil {
return errs.NewValidationError(
errors.New("after landing page cannot be both a page and a proxy"),
)
}
return nil
}
@@ -110,6 +157,14 @@ func (c *CampaignTemplate) ToDBMap() map[string]any {
}
}
if c.BeforeLandingProxyID.IsSpecified() {
if c.BeforeLandingProxyID.IsNull() {
m["before_landing_proxy_id"] = nil
} else {
m["before_landing_proxy_id"] = c.BeforeLandingProxyID.MustGet()
}
}
if c.LandingPageID.IsSpecified() {
if c.LandingPageID.IsNull() {
m["landing_page_id"] = nil
@@ -118,6 +173,14 @@ func (c *CampaignTemplate) ToDBMap() map[string]any {
}
}
if c.LandingProxyID.IsSpecified() {
if c.LandingProxyID.IsNull() {
m["landing_proxy_id"] = nil
} else {
m["landing_proxy_id"] = c.LandingProxyID.MustGet()
}
}
if c.AfterLandingPageID.IsSpecified() {
if c.AfterLandingPageID.IsNull() {
m["after_landing_page_id"] = nil
@@ -125,6 +188,14 @@ func (c *CampaignTemplate) ToDBMap() map[string]any {
m["after_landing_page_id"] = c.AfterLandingPageID.MustGet()
}
}
if c.AfterLandingProxyID.IsSpecified() {
if c.AfterLandingProxyID.IsNull() {
m["after_landing_proxy_id"] = nil
} else {
m["after_landing_proxy_id"] = c.AfterLandingProxyID.MustGet()
}
}
if c.AfterLandingPageRedirectURL.IsSpecified() {
if c.AfterLandingPageRedirectURL.IsNull() {
m["after_landing_page_redirect_url"] = nil
@@ -177,10 +248,14 @@ func (c *CampaignTemplate) ToDBMap() map[string]any {
_, errAPISender := c.APISenderID.Get()
_, errEmail := c.EmailID.Get()
_, errLandingPage := c.LandingPageID.Get()
_, errLandingProxy := c.LandingProxyID.Get()
// landing page is required (either page or proxy)
hasLanding := errLandingPage == nil || errLandingProxy == nil
m["is_usable"] = errDomain == nil &&
errEmail == nil &&
errLandingPage == nil &&
hasLanding &&
(errSMTP == nil || errAPISender == nil)
return m
+77 -27
View File
@@ -13,13 +13,15 @@ import (
)
type Domain struct {
ID nullable.Nullable[uuid.UUID] `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
Name nullable.Nullable[vo.String255] `json:"name"`
HostWebsite nullable.Nullable[bool] `json:"hostWebsite"`
ManagedTLS nullable.Nullable[bool] `json:"managedTLS"`
OwnManagedTLS nullable.Nullable[bool] `json:"ownManagedTLS"`
ID nullable.Nullable[uuid.UUID] `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
Name nullable.Nullable[vo.String255] `json:"name"`
Type nullable.Nullable[vo.String32] `json:"type"` // "regular" or "proxy"
ProxyTargetDomain nullable.Nullable[vo.OptionalString255] `json:"proxyTargetDomain"` // target URL for proxy (can be full URL or domain)
HostWebsite nullable.Nullable[bool] `json:"hostWebsite"`
ManagedTLS nullable.Nullable[bool] `json:"managedTLS"`
OwnManagedTLS nullable.Nullable[bool] `json:"ownManagedTLS"`
// private key
OwnManagedTLSKey nullable.Nullable[string] `json:"ownManagedTLSKey"`
// cert
@@ -28,6 +30,7 @@ type Domain struct {
PageNotFoundContent nullable.Nullable[vo.OptionalString1MB] `json:"pageNotFoundContent"`
RedirectURL nullable.Nullable[vo.OptionalString1024] `json:"redirectURL"`
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
ProxyID nullable.Nullable[uuid.UUID] `json:"proxyID"`
Company *Company `json:"company"`
}
@@ -36,20 +39,45 @@ func (d *Domain) Validate() error {
if err := validate.NullableFieldRequired("name", d.Name); err != nil {
return err
}
if err := validate.NullableFieldRequired("hostWebsite", d.HostWebsite); err != nil {
return err
// set default type if not specified
if !d.Type.IsSpecified() {
d.Type.Set(*vo.NewString32Must("regular"))
}
if err := validate.NullableFieldRequired("managedTLS", d.ManagedTLS); err != nil {
return err
domainType, err := d.Type.Get()
if err != nil {
return validate.WrapErrorWithField(errors.New("type is required"), "type")
}
if err := validate.NullableFieldRequired("pageContent", d.PageContent); err != nil {
return err
// validate type is either "regular" or "proxy"
if domainType.String() != "regular" && domainType.String() != "proxy" {
return validate.WrapErrorWithField(errors.New("type must be 'regular' or 'proxy'"), "type")
}
if err := validate.NullableFieldRequired("pageNotFoundContent", d.PageNotFoundContent); err != nil {
return err
}
if err := validate.NullableFieldRequired("redirectURL", d.RedirectURL); err != nil {
return err
if domainType.String() == "proxy" {
// proxy domains require proxyTargetDomain
if err := validate.NullableFieldRequired("proxyTargetDomain", d.ProxyTargetDomain); err != nil {
return err
}
// proxy domains don't need page content validation
} else {
// regular domains need standard validation
if err := validate.NullableFieldRequired("hostWebsite", d.HostWebsite); err != nil {
return err
}
if err := validate.NullableFieldRequired("managedTLS", d.ManagedTLS); err != nil {
return err
}
if err := validate.NullableFieldRequired("pageContent", d.PageContent); err != nil {
return err
}
if err := validate.NullableFieldRequired("pageNotFoundContent", d.PageNotFoundContent); err != nil {
return err
}
if err := validate.NullableFieldRequired("redirectURL", d.RedirectURL); err != nil {
return err
}
}
//
//
@@ -101,6 +129,18 @@ func (d *Domain) ToDBMap() map[string]any {
m["name"] = name.String()
}
}
if d.Type.IsSpecified() {
m["type"] = "regular"
if domainType, err := d.Type.Get(); err == nil {
m["type"] = domainType.String()
}
}
if d.ProxyTargetDomain.IsSpecified() {
m["proxy_target_domain"] = nil
if proxyTargetDomain, err := d.ProxyTargetDomain.Get(); err == nil {
m["proxy_target_domain"] = proxyTargetDomain.String()
}
}
if d.HostWebsite.IsSpecified() {
m["host_website"] = nil
if hostWebsite, err := d.HostWebsite.Get(); err == nil {
@@ -149,18 +189,28 @@ func (d *Domain) ToDBMap() map[string]any {
m["own_managed_tls"] = d.OwnManagedTLS.MustGet()
}
}
if d.ProxyID.IsSpecified() {
if d.ProxyID.IsNull() {
m["proxy_id"] = nil
} else {
m["proxy_id"] = d.ProxyID.MustGet()
}
}
return m
}
// DomainOverview is a subset of the domain as used as read-only
type DomainOverview struct {
ID uuid.UUID `json:"id,omitempty"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
Name string `json:"name"`
HostWebsite bool `json:"hostWebsite"`
ManagedTLS bool `json:"managedTLS"`
OwnManagedTLS bool `json:"ownManagedTLS"`
RedirectURL string `json:"redirectURL"`
CompanyID *uuid.UUID `json:"companyID"`
ID uuid.UUID `json:"id,omitempty"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
Name string `json:"name"`
Type string `json:"type"`
ProxyTargetDomain string `json:"proxyTargetDomain"`
HostWebsite bool `json:"hostWebsite"`
ManagedTLS bool `json:"managedTLS"`
OwnManagedTLS bool `json:"ownManagedTLS"`
RedirectURL string `json:"redirectURL"`
CompanyID *uuid.UUID `json:"companyID"`
ProxyID *uuid.UUID `json:"proxyID"`
}
+58 -8
View File
@@ -3,6 +3,7 @@ package model
import (
"time"
"github.com/go-errors/errors"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/validate"
@@ -11,12 +12,15 @@ import (
// Page is a Page
type Page struct {
ID nullable.Nullable[uuid.UUID] `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
Name nullable.Nullable[vo.String64] `json:"name"`
Content nullable.Nullable[vo.OptionalString1MB] `json:"content"`
ID nullable.Nullable[uuid.UUID] `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
Name nullable.Nullable[vo.String64] `json:"name"`
Content nullable.Nullable[vo.OptionalString1MB] `json:"content"`
Type nullable.Nullable[vo.String32] `json:"type"` // "regular" or "proxy"
TargetURL nullable.Nullable[vo.OptionalString1024] `json:"targetURL"` // target url for proxy pages
ProxyConfig nullable.Nullable[vo.OptionalString1MB] `json:"proxyConfig"` // yaml configuration for proxy
Company *Company `json:"-"`
}
@@ -26,9 +30,37 @@ func (p *Page) Validate() error {
if err := validate.NullableFieldRequired("name", p.Name); err != nil {
return err
}
if err := validate.NullableFieldRequired("content", p.Content); err != nil {
return err
// set default type if not specified
if !p.Type.IsSpecified() {
p.Type.Set(*vo.NewString32Must("regular"))
}
pageType, err := p.Type.Get()
if err != nil {
return validate.WrapErrorWithField(errors.New("type is required"), "type")
}
// validate type is either "regular" or "proxy"
if pageType.String() != "regular" && pageType.String() != "proxy" {
return validate.WrapErrorWithField(errors.New("type must be 'regular' or 'proxy'"), "type")
}
if pageType.String() == "proxy" {
// proxy pages require targetURL and proxyConfig
if err := validate.NullableFieldRequired("targetURL", p.TargetURL); err != nil {
return err
}
if err := validate.NullableFieldRequired("proxyConfig", p.ProxyConfig); err != nil {
return err
}
} else {
// regular pages require content
if err := validate.NullableFieldRequired("content", p.Content); err != nil {
return err
}
}
return nil
}
@@ -49,6 +81,24 @@ func (p *Page) ToDBMap() map[string]any {
m["content"] = content.String()
}
}
if p.Type.IsSpecified() {
m["type"] = "regular"
if pageType, err := p.Type.Get(); err == nil {
m["type"] = pageType.String()
}
}
if p.TargetURL.IsSpecified() {
m["target_url"] = nil
if targetURL, err := p.TargetURL.Get(); err == nil {
m["target_url"] = targetURL.String()
}
}
if p.ProxyConfig.IsSpecified() {
m["proxy_config"] = nil
if proxyConfig, err := p.ProxyConfig.Get(); err == nil {
m["proxy_config"] = proxyConfig.String()
}
}
if p.CompanyID.IsSpecified() {
if p.CompanyID.IsNull() {
m["company_id"] = nil
+108
View File
@@ -0,0 +1,108 @@
package model
import (
"time"
"github.com/go-errors/errors"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/validate"
"github.com/phishingclub/phishingclub/vo"
)
// Proxy is a proxy configuration
type Proxy struct {
ID nullable.Nullable[uuid.UUID] `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
Name nullable.Nullable[vo.String64] `json:"name"`
Description nullable.Nullable[vo.OptionalString1024] `json:"description"`
StartURL nullable.Nullable[vo.String1024] `json:"startURL"`
ProxyConfig nullable.Nullable[vo.String1MB] `json:"proxyConfig"`
Company *Company `json:"-"`
}
// Validate checks if the Proxy has a valid state
func (m *Proxy) Validate() error {
if err := validate.NullableFieldRequired("name", m.Name); err != nil {
return err
}
if err := validate.NullableFieldRequired("startURL", m.StartURL); err != nil {
return err
}
if err := validate.NullableFieldRequired("proxyConfig", m.ProxyConfig); err != nil {
return err
}
// validate start URL format
startURL, err := m.StartURL.Get()
if err != nil {
return validate.WrapErrorWithField(errors.New("start URL is required"), "startURL")
}
startURLStr := startURL.String()
if startURLStr == "" {
return validate.WrapErrorWithField(errors.New("start URL cannot be empty"), "startURL")
}
// validate that start URL is a valid, full URL
if err := validate.ErrorIfInvalidURL(startURLStr); err != nil {
return validate.WrapErrorWithField(err, "startURL")
}
return nil
}
// ToDBMap converts the fields that can be stored or updated to a map
// if the value is nullable and not set, it is not included
// if the value is nullable and set, it is included, if it is null, it is set to nil
func (m *Proxy) ToDBMap() map[string]any {
dbMap := map[string]any{}
if m.Name.IsSpecified() {
dbMap["name"] = nil
if name, err := m.Name.Get(); err == nil {
dbMap["name"] = name.String()
}
}
if m.Description.IsSpecified() {
dbMap["description"] = nil
if description, err := m.Description.Get(); err == nil {
dbMap["description"] = description.String()
}
}
if m.StartURL.IsSpecified() {
dbMap["start_url"] = nil
if startURL, err := m.StartURL.Get(); err == nil {
dbMap["start_url"] = startURL.String()
}
}
if m.ProxyConfig.IsSpecified() {
dbMap["proxy_config"] = nil
if proxyConfig, err := m.ProxyConfig.Get(); err == nil {
dbMap["proxy_config"] = proxyConfig.String()
}
}
if m.CompanyID.IsSpecified() {
if m.CompanyID.IsNull() {
dbMap["company_id"] = nil
} else {
dbMap["company_id"] = m.CompanyID.MustGet()
}
}
return dbMap
}
// ProxyOverview is a subset of the Proxy as used as read-only
type ProxyOverview struct {
ID uuid.UUID `json:"id,omitempty"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
Name string `json:"name"`
Description string `json:"description"`
StartURL string `json:"startURL"`
CompanyID *uuid.UUID `json:"companyID"`
}
File diff suppressed because it is too large Load Diff
+12
View File
@@ -38,3 +38,15 @@ func RandomIntN(n int) (int, error) {
}
return int(randNum.Int64()), nil
}
// GenerateRandomCookieName generates a random cookie name with length between 8-16 characters
func GenerateRandomCookieName() (string, error) {
// generate random length between 8 and 16
length, err := RandomIntN(9) // 0-8, add 8 to get 8-16
if err != nil {
return "", fmt.Errorf("failed to generate random cookie name length: %w", err)
}
length += 8 // now 8-16
return GenerateRandomURLBase64Encoded(length)
}
+173 -8
View File
@@ -35,14 +35,17 @@ type CampaignTemplateOption struct {
UsableOnly bool
WithCompany bool
WithDomain bool
WithLandingPage bool
WithBeforeLandingPage bool
WithAfterLandingPage bool
WithEmail bool
WithSMTPConfiguration bool
WithAPISender bool
WithCompany bool
WithDomain bool
WithLandingPage bool
WithBeforeLandingPage bool
WithAfterLandingPage bool
WithLandingProxy bool
WithBeforeLandingProxy bool
WithAfterLandingProxy bool
WithEmail bool
WithSMTPConfiguration bool
WithAPISender bool
// url and cookie keys
WithIdentifier bool
}
@@ -107,6 +110,45 @@ func (r CampaignTemplate) load(o *CampaignTemplateOption, db *gorm.DB) *gorm.DB
db = db.Preload("AfterLandingPage")
}
}
if o.WithLandingProxy {
if len(o.Columns) > 0 {
db = db.Joins(LeftJoinOnWithAlias(
database.CAMPAIGN_TEMPLATE_TABLE,
"landing_proxy_id",
database.PROXY_TABLE,
"id",
"landing_proxy",
))
} else {
db = db.Preload("LandingProxy")
}
}
if o.WithBeforeLandingProxy {
if len(o.Columns) > 0 {
db = db.Joins(LeftJoinOnWithAlias(
database.CAMPAIGN_TEMPLATE_TABLE,
"before_landing_proxy_id",
database.PROXY_TABLE,
"id",
"before_landing_proxy",
))
} else {
db = db.Preload("BeforeLandingProxy")
}
}
if o.WithAfterLandingProxy {
if len(o.Columns) > 0 {
db = db.Joins(LeftJoinOnWithAlias(
database.CAMPAIGN_TEMPLATE_TABLE,
"after_landing_proxy_id",
database.PROXY_TABLE,
"id",
"after_landing_proxy",
))
} else {
db = db.Preload("AfterLandingProxy")
}
}
if o.WithEmail {
if len(o.Columns) > 0 {
@@ -711,6 +753,18 @@ func ToCampaignTemplate(row *database.CampaignTemplate) (*model.CampaignTemplate
if row.BeforeLandingPageID != nil {
beforeLandingPageID.Set(*row.BeforeLandingPageID)
}
var beforeLandingProxy *model.Proxy
if row.BeforeLandingProxy != nil {
m, err := ToProxy(row.BeforeLandingProxy)
if err != nil {
return nil, errs.Wrap(err)
}
beforeLandingProxy = m
}
beforeLandingProxyID := nullable.NewNullNullable[uuid.UUID]()
if row.BeforeLandingProxyID != nil {
beforeLandingProxyID.Set(*row.BeforeLandingProxyID)
}
var landingPage *model.Page
if row.LandingPage != nil {
p, err := ToPage(row.LandingPage)
@@ -723,6 +777,18 @@ func ToCampaignTemplate(row *database.CampaignTemplate) (*model.CampaignTemplate
if row.LandingPageID != nil {
landingPageID.Set(*row.LandingPageID)
}
var landingProxy *model.Proxy
if row.LandingProxy != nil {
m, err := ToProxy(row.LandingProxy)
if err != nil {
return nil, errs.Wrap(err)
}
landingProxy = m
}
landingProxyID := nullable.NewNullNullable[uuid.UUID]()
if row.LandingProxyID != nil {
landingProxyID.Set(*row.LandingProxyID)
}
var afterLandingPage *model.Page
if row.AfterLandingPage != nil {
p, err := ToPage(row.AfterLandingPage)
@@ -735,6 +801,18 @@ func ToCampaignTemplate(row *database.CampaignTemplate) (*model.CampaignTemplate
if row.AfterLandingPageID != nil {
afterLandingPageID.Set(*row.AfterLandingPageID)
}
var afterLandingProxy *model.Proxy
if row.AfterLandingProxy != nil {
m, err := ToProxy(row.AfterLandingProxy)
if err != nil {
return nil, errs.Wrap(err)
}
afterLandingProxy = m
}
afterLandingProxyID := nullable.NewNullNullable[uuid.UUID]()
if row.AfterLandingProxyID != nil {
afterLandingProxyID.Set(*row.AfterLandingProxyID)
}
redirectURL := nullable.NewNullableWithValue(*vo.NewOptionalString255Must(""))
if row.AfterLandingPageRedirectURL != "" {
redirectURL.Set(*vo.NewOptionalString255Must(row.AfterLandingPageRedirectURL))
@@ -789,10 +867,16 @@ func ToCampaignTemplate(row *database.CampaignTemplate) (*model.CampaignTemplate
Domain: domain,
BeforeLandingPageID: beforeLandingPageID,
BeforeLandingePage: beforeLandingPage,
BeforeLandingProxyID: beforeLandingProxyID,
BeforeLandingProxy: beforeLandingProxy,
LandingPageID: landingPageID,
LandingPage: landingPage,
LandingProxyID: landingProxyID,
LandingProxy: landingProxy,
AfterLandingPageID: afterLandingPageID,
AfterLandingPage: afterLandingPage,
AfterLandingProxyID: afterLandingProxyID,
AfterLandingProxy: afterLandingProxy,
AfterLandingPageRedirectURL: redirectURL,
EmailID: emailID,
Email: email,
@@ -808,3 +892,84 @@ func ToCampaignTemplate(row *database.CampaignTemplate) (*model.CampaignTemplate
IsUsable: isUsable,
}, nil
}
// RemoveProxyIDFromAll removes the proxy ID from any matching columns
// landing_proxy_id, before_landing_proxy_id and after_landing_proxy_id
// GetByProxyID gets campaign templates that use a specific proxy ID
func (r *CampaignTemplate) GetByProxyID(
ctx context.Context,
proxyID *uuid.UUID,
options *CampaignTemplateOption,
) ([]*model.CampaignTemplate, error) {
db := r.DB
if options.Columns != nil && len(options.Columns) > 0 {
db = db.Select(strings.Join(options.Columns, ","))
}
db = r.load(options, db)
db, err := useQuery(db, database.CAMPAIGN_TEMPLATE_TABLE, options.QueryArgs, allowdCampaignTemplatesColumns...)
if err != nil {
return nil, errs.Wrap(err)
}
db = db.Where(
fmt.Sprintf(
"(%s = ? OR %s = ? OR %s = ?)",
TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "before_landing_proxy_id"),
TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "landing_proxy_id"),
TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "after_landing_proxy_id"),
),
proxyID.String(),
proxyID.String(),
proxyID.String(),
)
if options.UsableOnly {
db = db.Where(
fmt.Sprintf(
"%s = ?",
TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "is_usable"),
),
true,
)
}
var rows []*database.CampaignTemplate
res := db.Find(&rows)
if res.Error != nil {
return nil, res.Error
}
templates := []*model.CampaignTemplate{}
for _, row := range rows {
tmpl, err := ToCampaignTemplate(row)
if err != nil {
return nil, err
}
templates = append(templates, tmpl)
}
return templates, nil
}
func (r *CampaignTemplate) RemoveProxyIDFromAll(
ctx context.Context,
proxyID *uuid.UUID,
) error {
columns := []string{"before_landing_proxy_id", "after_landing_proxy_id", "landing_proxy_id"}
for _, column := range columns {
row := map[string]any{}
AddUpdatedAt(row)
row[column] = nil
row["is_usable"] = false
res := r.DB.
Model(&database.CampaignTemplate{}).
Where(
fmt.Sprintf(
"%s = ?",
TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, column),
),
proxyID.String(),
).
Updates(row)
if res.Error != nil {
return res.Error
}
}
return nil
}
+60 -9
View File
@@ -257,12 +257,27 @@ func ToDomain(row *database.Domain) *model.Domain {
if row.CompanyID != nil {
companyID.Set(*row.CompanyID)
}
proxyID := nullable.NewNullNullable[uuid.UUID]()
if row.ProxyID != nil {
proxyID.Set(*row.ProxyID)
}
var company *model.Company
if row.Company != nil {
company = ToCompany(row.Company)
}
id := nullable.NewNullableWithValue(row.ID)
name := nullable.NewNullableWithValue(*vo.NewString255Must(row.Name))
// Handle domain type
domainType := row.Type
if domainType == "" {
domainType = "regular"
}
domainTypeValue := nullable.NewNullableWithValue(*vo.NewString32Must(domainType))
// Handle proxy target domain
proxyTargetDomain := nullable.NewNullableWithValue(*vo.NewOptionalString255Must(row.ProxyTargetDomain))
managedTLS := nullable.NewNullableWithValue(row.ManagedTLSCerts)
ownManagedTLS := nullable.NewNullableWithValue(row.OwnManagedTLS)
hostWebsite := nullable.NewNullableWithValue(row.HostWebsite)
@@ -275,6 +290,8 @@ func ToDomain(row *database.Domain) *model.Domain {
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
Name: name,
Type: domainTypeValue,
ProxyTargetDomain: proxyTargetDomain,
ManagedTLS: managedTLS,
OwnManagedTLS: ownManagedTLS,
HostWebsite: hostWebsite,
@@ -282,21 +299,55 @@ func ToDomain(row *database.Domain) *model.Domain {
PageNotFoundContent: staticNotFound,
RedirectURL: redirectURL,
CompanyID: companyID,
ProxyID: proxyID,
Company: company,
}
}
// GetByProxyID gets domains by proxy ID
func (r *Domain) GetByProxyID(
ctx context.Context,
proxyID *uuid.UUID,
options *DomainOption,
) (*model.Result[model.Domain], error) {
result := model.NewEmptyResult[model.Domain]()
var dbDomains []database.Domain
db := r.DB
if options.WithCompany {
db = r.load(db)
}
db = db.Where("proxy_id = ?", proxyID)
dbRes := db.Find(&dbDomains)
if dbRes.Error != nil {
return result, dbRes.Error
}
for _, dbDomain := range dbDomains {
result.Rows = append(result.Rows, ToDomain(&dbDomain))
}
return result, nil
}
// ToDomainSubset converts a domain subset from db row to model
func ToDomainSubset(dbDomain *database.Domain) *model.DomainOverview {
domainType := dbDomain.Type
if domainType == "" {
domainType = "regular"
}
return &model.DomainOverview{
ID: dbDomain.ID,
CreatedAt: dbDomain.CreatedAt,
UpdatedAt: dbDomain.UpdatedAt,
Name: dbDomain.Name,
HostWebsite: dbDomain.HostWebsite,
ManagedTLS: dbDomain.ManagedTLSCerts,
OwnManagedTLS: dbDomain.OwnManagedTLS,
RedirectURL: dbDomain.RedirectURL,
CompanyID: dbDomain.CompanyID,
ID: dbDomain.ID,
CreatedAt: dbDomain.CreatedAt,
UpdatedAt: dbDomain.UpdatedAt,
Name: dbDomain.Name,
Type: domainType,
ProxyTargetDomain: dbDomain.ProxyTargetDomain,
HostWebsite: dbDomain.HostWebsite,
ManagedTLS: dbDomain.ManagedTLSCerts,
OwnManagedTLS: dbDomain.OwnManagedTLS,
RedirectURL: dbDomain.RedirectURL,
CompanyID: dbDomain.CompanyID,
ProxyID: dbDomain.ProxyID,
}
}
+28 -6
View File
@@ -254,12 +254,34 @@ func ToPage(row *database.Page) (*model.Page, error) {
}
content := nullable.NewNullableWithValue(*c)
// Handle proxy fields
typeValue := row.Type
if typeValue == "" {
typeValue = "regular"
}
pageType := nullable.NewNullableWithValue(*vo.NewString32Must(typeValue))
targetURL, err := vo.NewOptionalString1024(row.TargetURL)
if err != nil {
return nil, errs.Wrap(err)
}
targetURLNullable := nullable.NewNullableWithValue(*targetURL)
proxyConfig, err := vo.NewOptionalString1MB(row.ProxyConfig)
if err != nil {
return nil, errs.Wrap(err)
}
proxyConfigNullable := nullable.NewNullableWithValue(*proxyConfig)
return &model.Page{
ID: id,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
CompanyID: companyID,
Name: name,
Content: content,
ID: id,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
CompanyID: companyID,
Name: name,
Content: content,
Type: pageType,
TargetURL: targetURLNullable,
ProxyConfig: proxyConfigNullable,
}, nil
}
+299
View File
@@ -0,0 +1,299 @@
package repository
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/vo"
"gorm.io/gorm"
)
var proxyAllowedColumns = assignTableToColumns(database.PROXY_TABLE, []string{
"created_at",
"updated_at",
"name",
"start_url",
})
// ProxyOption is for eager loading
type ProxyOption struct {
Fields []string
*vo.QueryArgs
WithCompany bool
}
// Proxy is a proxy repository
type Proxy struct {
DB *gorm.DB
}
// load preloads the table relations
func (m *Proxy) load(
options *ProxyOption,
db *gorm.DB,
) *gorm.DB {
if options.WithCompany {
db = db.Joins("Company")
}
return db
}
// Insert inserts a proxy
func (m *Proxy) Insert(
ctx context.Context,
proxy *model.Proxy,
) (*uuid.UUID, error) {
id := uuid.New()
row := proxy.ToDBMap()
row["id"] = id
AddTimestamps(row)
res := m.DB.
Model(&database.Proxy{}).
Create(row)
if res.Error != nil {
return nil, res.Error
}
return &id, nil
}
// GetAll gets proxies
func (m *Proxy) GetAll(
ctx context.Context,
companyID *uuid.UUID,
options *ProxyOption,
) (*model.Result[model.Proxy], error) {
result := model.NewEmptyResult[model.Proxy]()
var dbProxies []database.Proxy
db := m.load(options, m.DB)
db = withCompanyIncludingNullContext(db, companyID, database.PROXY_TABLE)
db, err := useQuery(db, database.PROXY_TABLE, options.QueryArgs, proxyAllowedColumns...)
if err != nil {
return result, errs.Wrap(err)
}
if options.Fields != nil {
fields := assignTableToColumns(database.PROXY_TABLE, options.Fields)
db = db.Select(strings.Join(fields, ","))
}
dbRes := db.
Find(&dbProxies)
if dbRes.Error != nil {
return result, dbRes.Error
}
hasNextPage, err := useHasNextPage(db, database.PROXY_TABLE, options.QueryArgs, proxyAllowedColumns...)
if err != nil {
return result, errs.Wrap(err)
}
result.HasNextPage = hasNextPage
for _, dbProxy := range dbProxies {
proxy, err := ToProxy(&dbProxy)
if err != nil {
return result, errs.Wrap(err)
}
result.Rows = append(result.Rows, proxy)
}
return result, nil
}
// GetAllSubset gets proxies with limited data
func (m *Proxy) GetAllSubset(
ctx context.Context,
companyID *uuid.UUID,
options *ProxyOption,
) (*model.Result[model.ProxyOverview], error) {
result := model.NewEmptyResult[model.ProxyOverview]()
var dbProxies []database.Proxy
db := withCompanyIncludingNullContext(m.DB, companyID, database.PROXY_TABLE)
db, err := useQuery(db, database.PROXY_TABLE, options.QueryArgs, proxyAllowedColumns...)
if err != nil {
return result, errs.Wrap(err)
}
dbRes := db.
Select("id, created_at, updated_at, name, description, start_url, company_id").
Find(&dbProxies)
if dbRes.Error != nil {
return result, dbRes.Error
}
hasNextPage, err := useHasNextPage(db, database.PROXY_TABLE, options.QueryArgs, proxyAllowedColumns...)
if err != nil {
return result, errs.Wrap(err)
}
result.HasNextPage = hasNextPage
for _, dbProxy := range dbProxies {
proxyOverview := model.ProxyOverview{
ID: *dbProxy.ID,
CreatedAt: dbProxy.CreatedAt,
UpdatedAt: dbProxy.UpdatedAt,
Name: dbProxy.Name,
Description: dbProxy.Description,
StartURL: dbProxy.StartURL,
CompanyID: dbProxy.CompanyID,
}
result.Rows = append(result.Rows, &proxyOverview)
}
return result, nil
}
// GetAllByCompanyID gets proxies by company id
func (m *Proxy) GetAllByCompanyID(
ctx context.Context,
companyID *uuid.UUID,
options *ProxyOption,
) (*model.Result[model.Proxy], error) {
result := model.NewEmptyResult[model.Proxy]()
var dbProxies []database.Proxy
db := m.load(options, m.DB)
db = whereCompany(db, database.PROXY_TABLE, companyID)
db, err := useQuery(db, database.PROXY_TABLE, options.QueryArgs, proxyAllowedColumns...)
if err != nil {
return result, errs.Wrap(err)
}
if options.Fields != nil {
fields := assignTableToColumns(database.PROXY_TABLE, options.Fields)
db = db.Select(strings.Join(fields, ","))
}
dbRes := db.
Find(&dbProxies)
if dbRes.Error != nil {
return result, dbRes.Error
}
hasNextPage, err := useHasNextPage(db, database.PROXY_TABLE, options.QueryArgs, proxyAllowedColumns...)
if err != nil {
return result, errs.Wrap(err)
}
result.HasNextPage = hasNextPage
for _, dbProxy := range dbProxies {
proxy, err := ToProxy(&dbProxy)
if err != nil {
return result, errs.Wrap(err)
}
result.Rows = append(result.Rows, proxy)
}
return result, nil
}
// GetByID gets proxy by id
func (m *Proxy) GetByID(
ctx context.Context,
id *uuid.UUID,
options *ProxyOption,
) (*model.Proxy, error) {
dbProxy := database.Proxy{}
db := m.load(options, m.DB)
result := db.
Where(TableColumnID(database.PROXY_TABLE)+" = ?", id).
First(&dbProxy)
if result.Error != nil {
return nil, result.Error
}
return ToProxy(&dbProxy)
}
// GetByNameAndCompanyID gets proxy by name
func (m *Proxy) GetByNameAndCompanyID(
ctx context.Context,
name *vo.String64,
companyID *uuid.UUID, // can be null
options *ProxyOption,
) (*model.Proxy, error) {
proxy := database.Proxy{}
db := m.load(options, m.DB)
db = withCompanyIncludingNullContext(db, companyID, database.PROXY_TABLE)
result := db.
Where(
fmt.Sprintf(
"%s = ?",
TableColumn(database.PROXY_TABLE, "name"),
),
name.String(),
).
First(&proxy)
if result.Error != nil {
return nil, result.Error
}
return ToProxy(&proxy)
}
// UpdateByID updates a proxy by id
func (m *Proxy) UpdateByID(
ctx context.Context,
id *uuid.UUID,
proxy *model.Proxy,
) error {
row := proxy.ToDBMap()
AddUpdatedAt(row)
res := m.DB.
Model(&database.Proxy{}).
Where("id = ?", id).
Updates(row)
if res.Error != nil {
return res.Error
}
return nil
}
// DeleteByID deletes a proxy by id
func (m *Proxy) DeleteByID(
ctx context.Context,
id *uuid.UUID,
) error {
result := m.DB.Delete(&database.Proxy{}, id)
if result.Error != nil {
return result.Error
}
return nil
}
func ToProxy(row *database.Proxy) (*model.Proxy, error) {
id := nullable.NewNullableWithValue(*row.ID)
companyID := nullable.NewNullNullable[uuid.UUID]()
if row.CompanyID != nil {
companyID.Set(*row.CompanyID)
}
name := nullable.NewNullableWithValue(*vo.NewString64Must(row.Name))
description, err := vo.NewOptionalString1024(row.Description)
if err != nil {
return nil, errs.Wrap(err)
}
descriptionNullable := nullable.NewNullableWithValue(*description)
startURL := nullable.NewNullableWithValue(*vo.NewString1024Must(row.StartURL))
proxyConfig, err := vo.NewString1MB(row.ProxyConfig)
if err != nil {
return nil, errs.Wrap(err)
}
proxyConfigNullable := nullable.NewNullableWithValue(*proxyConfig)
return &model.Proxy{
ID: id,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
CompanyID: companyID,
Name: name,
Description: descriptionNullable,
StartURL: startURL,
ProxyConfig: proxyConfigNullable,
}, nil
}
+55 -1
View File
@@ -1,6 +1,8 @@
package seed
import (
"crypto/rand"
"github.com/go-errors/errors"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/app"
@@ -34,6 +36,7 @@ func initialInstallAndSeed(
&database.RecipientGroupRecipient{},
&database.Domain{},
&database.Page{},
&database.Proxy{},
&database.SMTPHeader{},
&database.SMTPConfiguration{},
&database.Email{},
@@ -51,16 +54,33 @@ func initialInstallAndSeed(
&database.Identifier{},
&database.CampaignStats{},
}
// disable foreign key constraints temporarily for sqlite to allow table recreation
logger.Debug("disabling foreign key constraints for migration")
err := db.Exec("PRAGMA foreign_keys = OFF").Error
if err != nil {
return errs.Wrap(errors.Errorf("failed to disable foreign keys: %w", err))
}
// create tables
logger.Debug("migrating tables")
err := db.AutoMigrate(
err = db.AutoMigrate(
tables...,
)
if err != nil {
// re-enable foreign keys before returning error
db.Exec("PRAGMA foreign_keys = ON")
return errs.Wrap(
errors.Errorf("failed to migrate database: %w", err),
)
}
// re-enable foreign key constraints
logger.Debug("re-enabling foreign key constraints after migration")
err = db.Exec("PRAGMA foreign_keys = ON").Error
if err != nil {
return errs.Wrap(errors.Errorf("failed to re-enable foreign keys: %w", err))
}
for _, table := range tables {
t, ok := table.(database.Migrater)
if !ok {
@@ -244,6 +264,40 @@ func SeedSettings(
}
}
}
{
// seed proxy cookie name
id := uuid.New()
var c int64
res := db.
Model(&database.Option{}).
Where("key = ?", data.OptionKeyProxyCookieName).
Count(&c)
if res.Error != nil {
return errs.Wrap(res.Error)
}
if c == 0 {
// generate random 8-character cookie name
b := make([]byte, 8)
_, err := rand.Read(b)
if err != nil {
return errs.Wrap(err)
}
charset := "abcdefghijklmnopqrstuvwxyz"
cookieName := ""
for i := range b {
cookieName += string(charset[int(b[i])%len(charset)])
}
res = db.Create(&database.Option{
ID: &id,
Key: data.OptionKeyProxyCookieName,
Value: cookieName,
})
if res.Error != nil {
return errs.Wrap(res.Error)
}
}
}
return nil
}
+58
View File
@@ -0,0 +1,58 @@
package server
import (
"context"
"net/http"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
)
// GetCampaignRecipientFromURLParams extracts campaign recipient information from URL parameters
// by checking all identifiers against query parameters and finding the first matching campaign recipient.
// returns the campaign recipient object, parameter name, and any error encountered.
func GetCampaignRecipientFromURLParams(
ctx context.Context,
req *http.Request,
identifierRepo *repository.Identifier,
campaignRecipientRepo *repository.CampaignRecipient,
) (*model.CampaignRecipient, string, error) {
// get all identifiers
identifiers, err := identifierRepo.GetAll(ctx, &repository.IdentifierOption{})
if err != nil {
return nil, "", err
}
query := req.URL.Query()
var matchingParams []struct {
name string
id *uuid.UUID
}
// collect all query parameters that match identifier names and can be parsed as UUIDs
for _, identifier := range identifiers.Rows {
if name := identifier.Name.MustGet(); query.Has(name) {
if id, err := uuid.Parse(query.Get(name)); err == nil {
matchingParams = append(matchingParams, struct {
name string
id *uuid.UUID
}{name: name, id: &id})
}
}
}
if len(matchingParams) == 0 {
return nil, "", nil
}
// check each matching parameter to find a valid campaign recipient
for _, param := range matchingParams {
campaignRecipient, err := campaignRecipientRepo.GetByCampaignRecipientID(ctx, param.id)
if err == nil && campaignRecipient != nil {
return campaignRecipient, param.name, nil
}
}
return nil, "", nil
}
+100
View File
@@ -587,24 +587,51 @@ func (c *CampaignTemplate) UpdateByID(
if campaignTemplate.BeforeLandingPageID.IsSpecified() {
if v, err := campaignTemplate.BeforeLandingPageID.Get(); err == nil {
incoming.BeforeLandingPageID.Set(v)
incoming.BeforeLandingProxyID.SetNull() // clear proxy if page is set
} else {
incoming.BeforeLandingPageID.SetNull()
}
}
if campaignTemplate.BeforeLandingProxyID.IsSpecified() {
if v, err := campaignTemplate.BeforeLandingProxyID.Get(); err == nil {
incoming.BeforeLandingProxyID.Set(v)
incoming.BeforeLandingPageID.SetNull() // clear page if proxy is set
} else {
incoming.BeforeLandingProxyID.SetNull()
}
}
if campaignTemplate.LandingPageID.IsSpecified() {
if v, err := campaignTemplate.LandingPageID.Get(); err == nil {
incoming.LandingPageID.Set(v)
incoming.LandingProxyID.SetNull() // clear proxy if page is set
} else {
incoming.LandingPageID.SetNull()
}
}
if campaignTemplate.LandingProxyID.IsSpecified() {
if v, err := campaignTemplate.LandingProxyID.Get(); err == nil {
incoming.LandingProxyID.Set(v)
incoming.LandingPageID.SetNull() // clear page if proxy is set
} else {
incoming.LandingProxyID.SetNull()
}
}
if campaignTemplate.AfterLandingPageID.IsSpecified() {
if v, err := campaignTemplate.AfterLandingPageID.Get(); err == nil {
incoming.AfterLandingPageID.Set(v)
incoming.AfterLandingProxyID.SetNull() // clear proxy if page is set
} else {
incoming.AfterLandingPageID.SetNull()
}
}
if campaignTemplate.AfterLandingProxyID.IsSpecified() {
if v, err := campaignTemplate.AfterLandingProxyID.Get(); err == nil {
incoming.AfterLandingProxyID.Set(v)
incoming.AfterLandingPageID.SetNull() // clear page if proxy is set
} else {
incoming.AfterLandingProxyID.SetNull()
}
}
if campaignTemplate.AfterLandingPageRedirectURL.IsSpecified() {
if v, err := campaignTemplate.AfterLandingPageRedirectURL.Get(); err == nil {
incoming.AfterLandingPageRedirectURL.Set(v)
@@ -704,3 +731,76 @@ func (c *CampaignTemplate) DeleteByID(
c.AuditLogAuthorized(ae)
return nil
}
// RemoveProxiesByProxyID removes the Proxy ID from templates
func (c *CampaignTemplate) RemoveProxiesByProxyID(
ctx context.Context,
session *model.Session,
proxyID *uuid.UUID,
) error {
ae := NewAuditEvent("CampaignTemplate.RemoveProxiesByProxyID", session)
ae.Details["proxyId"] = proxyID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return err
}
if !isAuthorized {
c.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// get all templates that use this proxy
templatesAffected, err := c.CampaignTemplateRepository.GetByProxyID(
ctx,
proxyID,
&repository.CampaignTemplateOption{},
)
if err != nil {
c.Logger.Errorw("failed to get affected campaign templates", "error", err)
return err
}
// get all campaigns using these templates and close active ones
templateIDs := []*uuid.UUID{}
for _, t := range templatesAffected {
id := t.ID.MustGet()
templateIDs = append(templateIDs, &id)
}
if len(templateIDs) > 0 {
campaignsAffected, err := c.CampaignRepository.GetByTemplateIDs(ctx, templateIDs)
if err != nil {
c.Logger.Errorw("failed to get affected campaigns by template IDs", "error", err)
return err
}
for _, campaign := range campaignsAffected {
if !campaign.IsActive() {
continue
}
err := campaign.Close()
if err != nil {
c.Logger.Errorw("failed to close campaign", "error", err)
}
campaignID := campaign.ID.MustGet()
err = c.CampaignRepository.UpdateByID(
ctx,
&campaignID,
campaign,
)
if err != nil {
c.Logger.Errorw("failed to update closed campaign", "error", err)
}
}
}
// remove the Proxy id from the templates
err = c.CampaignTemplateRepository.RemoveProxyIDFromAll(ctx, proxyID)
if err != nil {
c.Logger.Errorw("failed to remove Proxy ID from all campaign templates", "error", err)
return err
}
return nil
}
+306 -37
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/go-errors/errors"
@@ -39,11 +40,49 @@ type Domain struct {
TemplateService *Template
}
// CreateProxyDomain creates a proxy domain bypassing direct creation restrictions
func (d *Domain) CreateProxyDomain(
ctx context.Context,
session *model.Session,
domain *model.Domain,
) (*uuid.UUID, error) {
return d.createDomain(ctx, session, domain, true)
}
// Create creates a new domain
func (d *Domain) Create(
ctx context.Context,
session *model.Session,
domain *model.Domain,
) (*uuid.UUID, error) {
return d.createDomain(ctx, session, domain, false)
}
// DeleteProxyDomain deletes a proxy domain bypassing direct deletion restrictions
func (d *Domain) DeleteProxyDomain(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
) error {
return d.deleteDomain(ctx, session, id, true)
}
// UpdateProxyDomain updates a proxy domain bypassing direct update restrictions
func (d *Domain) UpdateProxyDomain(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
incoming *model.Domain,
) error {
return d.updateDomain(ctx, session, id, incoming, true)
}
// createDomain is the internal domain creation method
func (d *Domain) createDomain(
ctx context.Context,
session *model.Session,
domain *model.Domain,
allowProxyCreation bool,
) (*uuid.UUID, error) {
ae := NewAuditEvent("Domain.Create", session)
// check permissions
@@ -56,22 +95,40 @@ func (d *Domain) Create(
d.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// validate data
if err := domain.Validate(); err != nil {
// d.Logger.Debugf("failed to validate domain", "error", err)
return nil, errs.Wrap(err)
}
// validate template content if present
if pageContent, err := domain.PageContent.Get(); err == nil {
if err := d.TemplateService.ValidateDomainTemplate(pageContent.String()); err != nil {
d.Logger.Errorw("failed to validate domain page template", "error", err)
return nil, validate.WrapErrorWithField(errors.New("invalid page template: "+err.Error()), "pageContent")
// prevent direct creation of proxy domains unless explicitly allowed
if !allowProxyCreation {
if domainType, err := domain.Type.Get(); err == nil && domainType.String() == "proxy" {
return nil, validate.WrapErrorWithField(errors.New("proxy domains can only be created through proxy configuration, not directly"), "type")
}
}
if notFoundContent, err := domain.PageNotFoundContent.Get(); err == nil {
if err := d.TemplateService.ValidateDomainTemplate(notFoundContent.String()); err != nil {
d.Logger.Errorw("failed to validate domain not found template", "error", err)
return nil, validate.WrapErrorWithField(errors.New("invalid not found template: "+err.Error()), "pageNotFoundContent")
// validate data
if err := domain.Validate(); err != nil {
d.Logger.Errorw("failed to validate domain", "error", err)
return nil, errs.Wrap(err)
}
// get domain type for specific validation
domainType, _ := domain.Type.Get()
if domainType.String() == "proxy" {
// validate proxy target domain
if err := d.validateProxyDomain(ctx, domain); err != nil {
return nil, err
}
} else {
// validate template content for regular domains
if pageContent, err := domain.PageContent.Get(); err == nil {
if err := d.TemplateService.ValidateDomainTemplate(pageContent.String()); err != nil {
d.Logger.Errorw("failed to validate domain page template", "error", err)
return nil, validate.WrapErrorWithField(errors.New("invalid page template: "+err.Error()), "pageContent")
}
}
if notFoundContent, err := domain.PageNotFoundContent.Get(); err == nil {
if err := d.TemplateService.ValidateDomainTemplate(notFoundContent.String()); err != nil {
d.Logger.Errorw("failed to validate domain not found template", "error", err)
return nil, validate.WrapErrorWithField(errors.New("invalid not found template: "+err.Error()), "pageNotFoundContent")
}
}
}
// check for uniqueness
@@ -365,6 +422,17 @@ func (d *Domain) UpdateByID(
session *model.Session,
id *uuid.UUID,
incoming *model.Domain,
) error {
return d.updateDomain(ctx, session, id, incoming, false)
}
// updateDomain is the internal domain update method
func (d *Domain) updateDomain(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
incoming *model.Domain,
allowProxyUpdate bool,
) error {
ae := NewAuditEvent("Domain.UpdateByID", session)
ae.Details["id"] = id.String()
@@ -392,7 +460,78 @@ func (d *Domain) UpdateByID(
d.Logger.Errorw("failed to update domain", "error", err)
return err
}
// check if this is a proxy domain and restrict editable fields
isProxyDomain := false
if domainType, err := current.Type.Get(); err == nil && domainType.String() == "proxy" {
isProxyDomain = true
// for proxy domains, only allow updating ManagedTLS and custom certificate fields
if incoming.Type.IsSpecified() {
incomingType, _ := incoming.Type.Get()
if incomingType.String() != "proxy" {
return validate.WrapErrorWithField(errors.New("cannot change type of proxy domain"), "type")
}
}
} else {
// prevent changing regular domains to proxy type
if incoming.Type.IsSpecified() {
incomingType, _ := incoming.Type.Get()
if incomingType.String() == "proxy" {
return validate.WrapErrorWithField(errors.New("cannot change domain to proxy type - proxy domains can only be created through proxy configuration"), "type")
}
}
}
// set the supplied field on the existing domain
if isProxyDomain && !allowProxyUpdate {
// for proxy domains, prevent changing proxy-specific fields unless explicitly allowed
if incoming.ProxyTargetDomain.IsSpecified() {
return validate.WrapErrorWithField(errors.New("cannot change proxy target domain - edit the proxy configuration instead"), "proxyTargetDomain")
}
if incoming.HostWebsite.IsSpecified() {
return validate.WrapErrorWithField(errors.New("cannot change host website setting for proxy domain"), "hostWebsite")
}
if incoming.PageContent.IsSpecified() {
return validate.WrapErrorWithField(errors.New("cannot change page content for proxy domain"), "pageContent")
}
if incoming.PageNotFoundContent.IsSpecified() {
return validate.WrapErrorWithField(errors.New("cannot change page not found content for proxy domain"), "pageNotFoundContent")
}
if incoming.RedirectURL.IsSpecified() {
return validate.WrapErrorWithField(errors.New("cannot change redirect URL for proxy domain"), "redirectURL")
}
} else {
// for regular domains or proxy domains with allowed updates, allow updating all fields
if v, err := incoming.Type.Get(); err == nil {
current.Type.Set(v)
}
if v, err := incoming.ProxyTargetDomain.Get(); err == nil {
current.ProxyTargetDomain.Set(v)
}
if v, err := incoming.HostWebsite.Get(); err == nil {
current.HostWebsite.Set(v)
}
if v, err := incoming.PageContent.Get(); err == nil {
// validate template content before updating
if err := d.TemplateService.ValidateDomainTemplate(v.String()); err != nil {
d.Logger.Errorw("failed to validate domain page template", "error", err)
return validate.WrapErrorWithField(errors.New("invalid page template: "+err.Error()), "pageContent")
}
current.PageContent.Set(v)
}
if v, err := incoming.PageNotFoundContent.Get(); err == nil {
// validate template content before updating
if err := d.TemplateService.ValidateDomainTemplate(v.String()); err != nil {
d.Logger.Errorw("failed to validate domain not found template", "error", err)
return validate.WrapErrorWithField(errors.New("invalid not found template: "+err.Error()), "pageNotFoundContent")
}
current.PageNotFoundContent.Set(v)
}
if v, err := incoming.RedirectURL.Get(); err == nil {
current.RedirectURL.Set(v)
}
}
wasManagedTLS := current.ManagedTLS.MustGet()
if v, err := incoming.ManagedTLS.Get(); err == nil {
current.ManagedTLS.Set(v)
@@ -413,33 +552,33 @@ func (d *Domain) UpdateByID(
current.OwnManagedTLSPem.Set(v)
ownManagedTLSPemIsSet = len(v) > 0
}
if v, err := incoming.HostWebsite.Get(); err == nil {
current.HostWebsite.Set(v)
}
if v, err := incoming.PageContent.Get(); err == nil {
// validate template content before updating
if err := d.TemplateService.ValidateDomainTemplate(v.String()); err != nil {
d.Logger.Errorw("failed to validate domain page template", "error", err)
return validate.WrapErrorWithField(errors.New("invalid page template: "+err.Error()), "pageContent")
}
current.PageContent.Set(v)
}
if v, err := incoming.PageNotFoundContent.Get(); err == nil {
// validate template content before updating
if err := d.TemplateService.ValidateDomainTemplate(v.String()); err != nil {
d.Logger.Errorw("failed to validate domain not found template", "error", err)
return validate.WrapErrorWithField(errors.New("invalid not found template: "+err.Error()), "pageNotFoundContent")
}
current.PageNotFoundContent.Set(v)
}
if v, err := incoming.RedirectURL.Get(); err == nil {
current.RedirectURL.Set(v)
}
// validate
if err := current.Validate(); err != nil {
d.Logger.Errorw("failed to validate domain", "error", err)
return err
}
// validate proxy domain if type is proxy
if domainType, err := current.Type.Get(); err == nil && domainType.String() == "proxy" {
if err := d.validateProxyDomain(ctx, current); err != nil {
return err
}
} else {
// validate template content for regular domains only
if pageContent, err := current.PageContent.Get(); err == nil {
if err := d.TemplateService.ValidateDomainTemplate(pageContent.String()); err != nil {
d.Logger.Errorw("failed to validate domain page template", "error", err)
return validate.WrapErrorWithField(errors.New("invalid page template: "+err.Error()), "pageContent")
}
}
if notFoundContent, err := current.PageNotFoundContent.Get(); err == nil {
if err := d.TemplateService.ValidateDomainTemplate(notFoundContent.String()); err != nil {
d.Logger.Errorw("failed to validate domain not found template", "error", err)
return validate.WrapErrorWithField(errors.New("invalid not found template: "+err.Error()), "pageNotFoundContent")
}
}
}
// clean up if TLS was previous managed but no longer is
if managedTLS, err := incoming.ManagedTLS.Get(); err == nil && !managedTLS {
if wasManagedTLS {
@@ -491,11 +630,21 @@ func (d *Domain) UpdateByID(
return nil
}
// DeleteByID
// DeleteByID deletes a domain by ID
func (d *Domain) DeleteByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
) error {
return d.deleteDomain(ctx, session, id, false)
}
// deleteDomain is the internal domain deletion method
func (d *Domain) deleteDomain(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
allowProxyDeletion bool,
) error {
ae := NewAuditEvent("Domain.DeleteByID", session)
ae.Details["id"] = id.String()
@@ -509,6 +658,24 @@ func (d *Domain) DeleteByID(
d.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// get the domain to check if it's a proxy domain
current, err := d.DomainRepository.GetByID(ctx, id, &repository.DomainOption{})
if errors.Is(err, gorm.ErrRecordNotFound) {
d.Logger.Debugw("domain not found", "error", err)
return err
}
if err != nil {
d.Logger.Errorw("failed to get domain for deletion", "error", err)
return err
}
// prevent deletion of proxy domains unless explicitly allowed
if !allowProxyDeletion {
if domainType, err := current.Type.Get(); err == nil && domainType.String() == "proxy" {
return validate.WrapErrorWithField(errors.New("proxy domains can only be deleted by deleting the associated proxy configuration"), "domain")
}
}
// get the domain
domain, err := d.DomainRepository.GetByID(
ctx,
@@ -696,3 +863,105 @@ func (d *Domain) removeOwnManagedTLS(
}
return nil
}
// validateProxyDomain validates proxy domain configuration
func (d *Domain) validateProxyDomain(ctx context.Context, domain *model.Domain) error {
// validate proxy target domain format
proxyTargetDomain, err := domain.ProxyTargetDomain.Get()
if err != nil {
return validate.WrapErrorWithField(errors.New("proxy target domain is required for proxy domains"), "proxyTargetDomain")
}
targetDomainStr := proxyTargetDomain.String()
if targetDomainStr == "" {
return validate.WrapErrorWithField(errors.New("proxy target domain cannot be empty"), "proxyTargetDomain")
}
// validate proxy target format - can be full URL or domain
if strings.Contains(targetDomainStr, "://") {
// full URL format - basic validation
if !strings.HasPrefix(targetDomainStr, "http://") && !strings.HasPrefix(targetDomainStr, "https://") {
return validate.WrapErrorWithField(errors.New("proxy target domain URL must start with http:// or https://"), "proxyTargetDomain")
}
} else {
// domain-only format - validate as domain
if !isValidDomain(targetDomainStr) {
return validate.WrapErrorWithField(errors.New("invalid domain format for proxy target domain"), "proxyTargetDomain")
}
}
return nil
}
// isValidDomain performs basic domain name validation
func isValidDomain(domain string) bool {
// basic checks - must have at least one dot and valid characters
if len(domain) == 0 || len(domain) > 253 {
return false
}
// must contain at least one dot
if !strings.Contains(domain, ".") {
return false
}
// cannot start or end with dash or dot
if strings.HasPrefix(domain, "-") || strings.HasSuffix(domain, "-") ||
strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
return false
}
// check each label
labels := strings.Split(domain, ".")
for _, label := range labels {
if len(label) == 0 || len(label) > 63 {
return false
}
// label cannot start or end with dash
if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
return false
}
// basic character check - alphanumeric and dash only
for _, char := range label {
if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') ||
(char >= '0' && char <= '9') || char == '-') {
return false
}
}
}
return true
}
// GetByProxyID gets domains by proxy ID
func (d *Domain) GetByProxyID(
ctx context.Context,
session *model.Session,
proxyID *uuid.UUID,
) (*model.Result[model.Domain], error) {
ae := NewAuditEvent("Domain.GetByProxyID", session)
ae.Details["proxyID"] = proxyID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
d.LogAuthError(err)
return nil, err
}
if !isAuthorized {
d.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
result, err := d.DomainRepository.GetByProxyID(
ctx,
proxyID,
&repository.DomainOption{},
)
if err != nil {
d.Logger.Errorw("failed to get domains by proxy id", "error", err)
return nil, errs.Wrap(err)
}
// no audit on read
return result, nil
}
+194 -10
View File
@@ -2,8 +2,12 @@ package service
import (
"context"
"net/url"
"regexp"
"strings"
"github.com/go-errors/errors"
"gopkg.in/yaml.v3"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/data"
@@ -11,6 +15,7 @@ import (
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/validate"
"github.com/phishingclub/phishingclub/vo"
"gorm.io/gorm"
)
@@ -21,6 +26,40 @@ type Page struct {
CampaignRepository *repository.Campaign
CampaignTemplateService *CampaignTemplate
TemplateService *Template
DomainRepository *repository.Domain
}
// ProxyConfig represents the YAML configuration for proxy pages
type ProxyConfig struct {
Default map[string]interface{} `yaml:"default,omitempty"`
Hosts map[string]ProxyHostConfig `yaml:",inline"`
}
// ProxyHostConfig represents configuration for a specific host
type ProxyHostConfig struct {
Proxy string `yaml:"proxy,omitempty"`
Domain string `yaml:"domain,omitempty"`
Capture []ProxyCaptureRule `yaml:"capture,omitempty"`
Replace []ProxyReplaceRule `yaml:"replace,omitempty"`
}
// ProxyCaptureRule represents a capture rule
type ProxyCaptureRule struct {
Name string `yaml:"name"`
Method string `yaml:"method,omitempty"`
Path string `yaml:"path,omitempty"`
Pattern string `yaml:"pattern,omitempty"`
Find string `yaml:"find"`
From string `yaml:"from,omitempty"`
Required *bool `yaml:"required,omitempty"`
}
// ProxyReplaceRule represents a replace rule
type ProxyReplaceRule struct {
Name string `yaml:"name"`
Find string `yaml:"find"`
Replace string `yaml:"replace"`
From string `yaml:"from,omitempty"`
}
// Create creates a new page
@@ -50,11 +89,20 @@ func (p *Page) Create(
p.Logger.Errorw("failed to validate page", "error", err)
return nil, errs.Wrap(err)
}
// validate template content if present
if content, err := page.Content.Get(); err == nil {
if err := p.TemplateService.ValidatePageTemplate(content.String()); err != nil {
p.Logger.Errorw("failed to validate page template", "error", err)
return nil, validate.WrapErrorWithField(errors.New("invalid template: "+err.Error()), "content")
// validate based on page type
pageType, _ := page.Type.Get()
if pageType.String() == "proxy" {
// validate proxy configuration
if err := p.validateProxyPage(ctx, page); err != nil {
return nil, err
}
} else {
// validate template content for regular pages
if content, err := page.Content.Get(); err == nil {
if err := p.TemplateService.ValidatePageTemplate(content.String()); err != nil {
p.Logger.Errorw("failed to validate page template", "error", err)
return nil, validate.WrapErrorWithField(errors.New("invalid template: "+err.Error()), "content")
}
}
}
// check uniqueness
@@ -260,14 +308,35 @@ func (p *Page) UpdateByID(
}
current.Name.Set(v)
}
if v, err := page.Type.Get(); err == nil {
current.Type.Set(v)
}
if v, err := page.TargetURL.Get(); err == nil {
current.TargetURL.Set(v)
}
if v, err := page.ProxyConfig.Get(); err == nil {
current.ProxyConfig.Set(v)
}
if v, err := page.Content.Get(); err == nil {
// validate template content before updating
if err := p.TemplateService.ValidatePageTemplate(v.String()); err != nil {
p.Logger.Errorw("failed to validate page template", "error", err)
return validate.WrapErrorWithField(errors.New("invalid template: "+err.Error()), "content")
}
current.Content.Set(v)
}
// validate based on updated page type
updatedPageType, _ := current.Type.Get()
if updatedPageType.String() == "proxy" {
// validate proxy configuration
if err := p.validateProxyPage(ctx, current); err != nil {
return err
}
} else {
// validate template content for regular pages
if content, err := current.Content.Get(); err == nil {
if err := p.TemplateService.ValidatePageTemplate(content.String()); err != nil {
p.Logger.Errorw("failed to validate page template", "error", err)
return validate.WrapErrorWithField(errors.New("invalid template: "+err.Error()), "content")
}
}
}
// update page
err = p.PageRepository.UpdateByID(
ctx,
@@ -284,6 +353,121 @@ func (p *Page) UpdateByID(
return nil
}
// validateProxyPage validates proxy page configuration
func (p *Page) validateProxyPage(ctx context.Context, page *model.Page) error {
// validate target URL format
targetURL, err := page.TargetURL.Get()
if err != nil {
return validate.WrapErrorWithField(errors.New("target URL is required for proxy pages"), "targetURL")
}
parsedURL, err := url.Parse(targetURL.String())
if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
return validate.WrapErrorWithField(errors.New("invalid target URL format - must be a valid HTTP or HTTPS URL"), "targetURL")
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return validate.WrapErrorWithField(errors.New("target URL must use HTTP or HTTPS protocol"), "targetURL")
}
// validate proxy configuration YAML
proxyConfig, err := page.ProxyConfig.Get()
if err != nil {
return validate.WrapErrorWithField(errors.New("proxy configuration is required for proxy pages"), "proxyConfig")
}
var config ProxyConfig
if err := yaml.Unmarshal([]byte(proxyConfig.String()), &config); err != nil {
return validate.WrapErrorWithField(errors.New("invalid YAML format: "+err.Error()), "proxyConfig")
}
// validate that all referenced domains in the config support proxy
for hostname, hostConfig := range config.Hosts {
if hostConfig.Domain != "" {
domainName, err := vo.NewString255(hostConfig.Domain)
if err != nil {
return validate.WrapErrorWithField(
errors.New("invalid domain name format"),
"proxyConfig",
)
}
_, err = p.DomainRepository.GetByName(ctx, domainName, &repository.DomainOption{})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return validate.WrapErrorWithField(
errors.New("referenced domain '"+hostConfig.Domain+"' not found"),
"proxyConfig",
)
}
return err
}
}
// validate capture rules
for _, capture := range hostConfig.Capture {
if capture.Name == "" {
return validate.WrapErrorWithField(errors.New("capture rule name is required"), "proxyConfig")
}
if capture.Pattern == "" && capture.Path == "" {
return validate.WrapErrorWithField(
errors.New("capture rule must have either pattern or path"),
"proxyConfig",
)
}
if capture.Pattern != "" {
if _, err := regexp.Compile(capture.Pattern); err != nil {
return validate.WrapErrorWithField(
errors.New("invalid regex pattern in capture rule: "+err.Error()),
"proxyConfig",
)
}
}
if capture.Path != "" {
if _, err := regexp.Compile(capture.Path); err != nil {
return validate.WrapErrorWithField(
errors.New("invalid regex pattern for path in capture rule: "+err.Error()),
"proxyConfig",
)
}
}
if capture.From != "" {
validFromValues := []string{"request_body", "request_header", "response_body", "response_header", "any"}
valid := false
for _, validFrom := range validFromValues {
if capture.From == validFrom {
valid = true
break
}
}
if !valid {
return validate.WrapErrorWithField(
errors.New("invalid 'from' value in capture rule, must be one of: "+strings.Join(validFromValues, ", ")),
"proxyConfig",
)
}
}
}
// validate replace rules
for _, replace := range hostConfig.Replace {
if replace.Find == "" {
return validate.WrapErrorWithField(errors.New("replace rule 'find' is required"), "proxyConfig")
}
if _, err := regexp.Compile(replace.Find); err != nil {
return validate.WrapErrorWithField(
errors.New("invalid regex pattern in replace rule 'find': "+err.Error()),
"proxyConfig",
)
}
}
p.Logger.Debugw("validated proxy host config", "hostname", hostname)
}
return nil
}
// DeleteByID deletes a page by ID
func (p *Page) DeleteByID(
ctx context.Context,
File diff suppressed because it is too large Load Diff
+86
View File
@@ -6,6 +6,7 @@ package validate
import (
"fmt"
"net/mail"
"net/url"
"regexp"
"slices"
"strings"
@@ -412,3 +413,88 @@ func OneOfNullableFieldsRequired(fields map[string]any) error {
keys := utils.MapKeys(fields)
return fmt.Errorf("one of the fields (%s) must be supplied", strings.Join(keys, ", "))
}
// ErrorIfInvalidURL validates that a string is a valid URL with http/https scheme
func ErrorIfInvalidURL(urlStr string) error {
if urlStr == "" {
return errs.NewValidationError(
errors.New("URL cannot be empty"),
)
}
// validate that URL is parseable
parsedURL, err := url.Parse(urlStr)
if err != nil {
return errs.NewValidationError(
errors.New("must be a valid URL"),
)
}
// ensure it has a valid scheme (http or https)
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return errs.NewValidationError(
errors.New("must use http or https protocol"),
)
}
// ensure it has a valid host
if parsedURL.Host == "" {
return errs.NewValidationError(
errors.New("must have a valid host"),
)
}
// extract hostname (removes port if present) for domain validation
hostname := parsedURL.Hostname()
if hostname == "" {
return errs.NewValidationError(
errors.New("must have a valid hostname"),
)
}
// basic domain validation
if !isValidDomain(hostname) {
return errs.NewValidationError(
errors.New("must have a valid domain"),
)
}
return nil
}
// isValidDomain performs basic domain name validation
// supports international domain names (IDNs)
func isValidDomain(domain string) bool {
// basic checks - length limits
if len(domain) == 0 || len(domain) > 253 {
return false
}
// must contain at least one dot
if !strings.Contains(domain, ".") {
return false
}
// cannot start or end with dash or dot
if strings.HasPrefix(domain, "-") || strings.HasSuffix(domain, "-") ||
strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
return false
}
// check each label
labels := strings.Split(domain, ".")
for _, label := range labels {
if len(label) == 0 || len(label) > 63 {
return false
}
// label cannot start or end with dash
if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
return false
}
// removed restrictive ascii-only character check to support international domains
}
return true
}
+54
View File
@@ -12,6 +12,60 @@ import (
"github.com/phishingclub/phishingclub/validate"
)
// String32 is a trimmed string with a min of 1 and a max of 32
type String32 struct {
inner string
}
// NewString32 creates a new short string
func NewString32(s string) (*String32, error) {
s = strings.TrimSpace(s)
err := validate.ErrorIfStringNotbetweenOrEqualTo(s, 1, 32)
if err != nil {
return nil, errs.Wrap(err)
}
return &String32{
inner: s,
}, nil
}
// NewString32Must creates a new short string and panics if it fails
func NewString32Must(s string) *String32 {
a, err := NewString32(s)
if err != nil {
panic(err)
}
return a
}
// MarshalJSON implements the json.Marshaler interface
func (s String32) MarshalJSON() ([]byte, error) {
return json.Marshal(s.inner)
}
// UnmarshalJSON unmarshals the json into a string
func (s *String32) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
ss, err := NewString32(str)
if err != nil {
unwrapped := errors.Unwrap(err)
if unwrapped == nil {
return err
}
return unwrapped
}
s.inner = ss.inner
return nil
}
// String returns the string representation of the short string
func (s String32) String() string {
return s.inner
}
// String64 is a trimmed string with a min of 1 and a max of 64
type String64 struct {
inner string
+20
View File
@@ -150,6 +150,23 @@ services:
networks:
- default
# mitmproxy - HTTP/HTTPS proxy for security research and debugging
# Web interface: http://localhost:8105 (check logs for auto-generated token) - Proxy available at 172.20.0.138:8080
# Use this IP in your Proxy configs: proxy: '172.20.0.138:8080'
mitmproxy:
image: mitmproxy/mitmproxy:latest
command: mitmweb --web-host 0.0.0.0 --web-port 8080 --listen-port 8081 --no-web-open-browser
tty: true
ports:
- "8105:8080" # Web interface
- "8106:8081" # Proxy port (for external access)
volumes:
- mitmproxy_data:/home/mitmproxy/.mitmproxy
restart: unless-stopped
networks:
default:
ipv4_address: 172.20.0.138
# DNS Server for .test domain resolution
dns:
restart: always
@@ -166,6 +183,9 @@ services:
default:
ipv4_address: 172.20.0.137
volumes:
mitmproxy_data:
networks:
default:
driver: bridge
+138 -14
View File
@@ -907,8 +907,11 @@ export class API {
* @param {string} template.companyID
* @param {string} template.domainID
* @param {string} template.beforeLandingPageID
* @param {string} template.beforeLandingProxyID
* @param {string} template.afterLandingPageID
* @param {string} template.afterLandingProxyID
* @param {string} template.landingPageID
* @param {string} template.landingProxyID
* @param {string} template.smtpConfigurationID
* @param {string} template.apiSenderID
* @param {string} template.afterLandingPageRedirectURL
@@ -923,8 +926,11 @@ export class API {
companyID,
domainID,
beforeLandingPageID,
beforeLandingProxyID,
afterLandingPageID,
afterLandingProxyID,
landingPageID,
landingProxyID,
smtpConfigurationID,
apiSenderID,
urlIdentifierID,
@@ -937,8 +943,11 @@ export class API {
companyID: companyID,
domainID: domainID,
beforeLandingPageID: beforeLandingPageID,
beforeLandingProxyID: beforeLandingProxyID,
afterLandingPageID: afterLandingPageID,
afterLandingProxyID: afterLandingProxyID,
landingPageID: landingPageID,
landingProxyID: landingProxyID,
smtpConfigurationID: smtpConfigurationID,
apiSenderID: apiSenderID,
afterLandingPageRedirectURL: afterLandingPageRedirectURL,
@@ -957,8 +966,11 @@ export class API {
* @param {string} template.companyID
* @param {string} template.domainID
* @param {string} template.beforeLandingPageID
* @param {string} template.beforeLandingProxyID
* @param {string} template.afterLandingPageID
* @param {string} template.afterLandingProxyID
* @param {string} template.landingPageID
* @param {string} template.landingProxyID
* @param {string} template.smtpConfigurationID
* @param {string} template.apiSenderID
* @param {string} template.afterLandingPageRedirectURL
@@ -974,8 +986,11 @@ export class API {
companyID,
domainID,
beforeLandingPageID,
beforeLandingProxyID,
afterLandingPageID,
afterLandingProxyID,
landingPageID,
landingProxyID,
smtpConfigurationID,
apiSenderID,
afterLandingPageRedirectURL,
@@ -989,8 +1004,11 @@ export class API {
companyID: companyID,
domainID: domainID,
beforeLandingPageID: beforeLandingPageID,
beforeLandingProxyID: beforeLandingProxyID,
afterLandingPageID: afterLandingPageID,
afterLandingProxyID: afterLandingProxyID,
landingPageID: landingPageID,
landingProxyID: landingProxyID,
smtpConfigurationID: smtpConfigurationID,
apiSenderID: apiSenderID,
afterLandingPageRedirectURL: afterLandingPageRedirectURL,
@@ -1092,6 +1110,8 @@ export class API {
*
* @param {object} domain
* @param {string} domain.name
* @param {string} domain.type
* @param {string} domain.proxyTargetDomain
* @param {boolean} domain.managedTLS
* @param {boolean} domain.ownManagedTLS
* @param {string} domain.ownManagedTLSKey
@@ -1105,6 +1125,8 @@ export class API {
*/
create: async ({
name,
type,
proxyTargetDomain,
managedTLS,
ownManagedTLS,
ownManagedTLSKey,
@@ -1117,6 +1139,8 @@ export class API {
}) => {
return await postJSON(this.getPath('/domain/'), {
name: name,
type: type,
proxyTargetDomain: proxyTargetDomain,
managedTLS: managedTLS,
ownManagedTLS: ownManagedTLS,
ownManagedTLSKey: ownManagedTLSKey,
@@ -1134,19 +1158,23 @@ export class API {
*
* @param {object} domain
* @param {string} domain.id
* @param {string} [domain.type]
* @param {string} [domain.proxyTargetDomain]
* @param {boolean} domain.managedTLS
* @param {boolean} domain.ownManagedTLS
* @param {string} domain.ownManagedTLSKey
* @param {string} domain.ownManagedTLSPem
* @param {boolean} domain.hostWebsite
* @param {string} domain.pageContent
* @param {string} domain.pageNotFoundContent
* @param {string} domain.redirectURL
* @param {boolean} [domain.hostWebsite]
* @param {string} [domain.pageContent]
* @param {string} [domain.pageNotFoundContent]
* @param {string} [domain.redirectURL]
* @param {string} domain.companyID
* @returns {Promise<ApiResponse>}
*/
update: async ({
id,
type,
proxyTargetDomain,
managedTLS,
ownManagedTLS,
ownManagedTLSKey,
@@ -1157,17 +1185,23 @@ export class API {
redirectURL,
companyID
}) => {
return await postJSON(this.getPath(`/domain/${id}`), {
hostWebsite: hostWebsite,
const payload = {
managedTLS: managedTLS,
ownManagedTLS: ownManagedTLS,
ownManagedTLSKey: ownManagedTLSKey,
ownManagedTLSPem: ownManagedTLSPem,
pageContent: pageContent,
pageNotFoundContent: pageNotFoundContent,
redirectURL: redirectURL,
companyID: companyID
});
};
// conditionally add fields if they are provided
if (type !== undefined) payload.type = type;
if (proxyTargetDomain !== undefined) payload.proxyTargetDomain = proxyTargetDomain;
if (hostWebsite !== undefined) payload.hostWebsite = hostWebsite;
if (pageContent !== undefined) payload.pageContent = pageContent;
if (pageNotFoundContent !== undefined) payload.pageNotFoundContent = pageNotFoundContent;
if (redirectURL !== undefined) payload.redirectURL = redirectURL;
return await postJSON(this.getPath(`/domain/${id}`), payload);
},
/**
@@ -1280,14 +1314,17 @@ export class API {
* @param {string} name
* @param {string} content
* @param {string} companyID
* @param {object} additionalFields - Optional additional fields for Proxy pages
* @returns {Promise<ApiResponse>}
*/
create: async (name, content, companyID) => {
return await postJSON(this.getPath('/page'), {
create: async (name, content, companyID, additionalFields = {}) => {
const payload = {
name: name,
content: content,
companyID: companyID
});
companyID: companyID,
...additionalFields
};
return await postJSON(this.getPath('/page'), payload);
},
/**
@@ -2715,6 +2752,93 @@ export class API {
}
};
/**
* proxy is the API for Proxy related operations.
*/
proxy = {
/**
* Get a Proxy by its ID.
*
* @param {string} id
* @returns {Promise<ApiResponse>}
*/
getByID: async (id) => {
return await getJSON(this.getPath(`/proxy/${id}`));
},
/**
* Get all Proxies using pagination.
*
* @param {TableURLParams} options
* @param {string|null} companyID
* @returns {Promise<ApiResponse>}
*/
getAll: async (options, companyID = null) => {
return await getJSON(
this.getPath(`/proxy?${appendQuery(options)}${this.appendCompanyQuery(companyID)}`)
);
},
/**
* Get all Proxies overview using pagination.
*
* @param {TableURLParams} options
* @param {string|null} companyID
* @returns {Promise<ApiResponse>}
*/
getAllSubset: async (options, companyID = null) => {
return await getJSON(
this.getPath(`/proxy/overview?${appendQuery(options)}${this.appendCompanyQuery(companyID)}`)
);
},
/**
* Create a new Proxy.
*
* @param {object} proxy
* @param {string} proxy.name
* @param {string} proxy.description
* @param {string} proxy.startURL
* @param {string} proxy.proxyConfig
* @param {string} proxy.companyID
* @returns {Promise<ApiResponse>}
*/
create: async ({ name, description, startURL, proxyConfig, companyID }) => {
return await postJSON(this.getPath('/proxy'), {
name: name,
description: description,
startURL: startURL,
proxyConfig: proxyConfig,
companyID: companyID
});
},
/**
* Update a Proxy.
*
* @param {string} id
* @param {object} proxy
* @param {string} proxy.name
* @param {string} proxy.description
* @param {string} proxy.startURL
* @param {string} proxy.proxyConfig
* @returns {Promise<ApiResponse>}
*/
update: async (id, proxy) => {
return await patchJSON(this.getPath(`/proxy/${id}`), proxy);
},
/**
* Delete a Proxy.
*
* @param {string} id
* @returns {Promise<ApiResponse>}
*/
delete: async (id) => {
return await deleteJSON(this.getPath(`/proxy/${id}`));
}
};
/**
* import is for importing assets, landing pages and etc
*/
@@ -0,0 +1,21 @@
<script>
export let size = 'w-5 h-5';
export let className = '';
export let title = 'Proxy';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="{size} {className}"
>
<title>{title}</title>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"
/>
</svg>
@@ -103,6 +103,7 @@
onMount(() => {
document.body.classList.add('overflow-hidden');
/* ts-ignore */
self.MonacoEnvironment = {
getWorker: function (_, label) {
if (label === 'html') {
@@ -51,6 +51,7 @@
editor.dispose();
}
};
/* @ts-ignore */
self.MonacoEnvironment = {
getWorker: function (_, label) {
if (label === 'json') {
@@ -145,7 +146,7 @@
</button>
</div>
<pre
class="text-xs text-gray-600 dark:text-gray-300 whitespace-pre-wrap transition-colors duration-200">{placeholder}</pre>
class="text-xs text-gray-600 dark:text-gray-300 whitespace-pre-wrap transition-colors duration-200 select-text cursor-text">{placeholder}</pre>
</div>
{/if}
</div>
@@ -0,0 +1,40 @@
<script>
import ProxySvgIcon from '$lib/components/ProxySvgIcon.svelte';
export let value = 'page'; // 'page' or 'proxy'
export let disabled = false;
const options = [
{ value: 'page', icon: '📄', label: 'Page' },
{ value: 'proxy', icon: 'proxy', label: 'Proxy' }
];
function handleChange(newValue) {
if (!disabled) {
value = newValue;
}
}
</script>
<div class="inline-flex bg-gray-100 dark:bg-gray-700 rounded p-0.5">
{#each options as option}
<button
type="button"
class="w-6 h-6 text-xs rounded transition-colors duration-150 flex items-center justify-center {value ===
option.value
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white'} {disabled
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer'}"
on:click={() => handleChange(option.value)}
{disabled}
title={option.label}
>
{#if option.icon === 'proxy'}
<ProxySvgIcon size="w-4 h-4" />
{:else}
{option.icon}
{/if}
</button>
{/each}
</div>
@@ -0,0 +1,401 @@
<script>
import { afterUpdate, onDestroy, onMount } from 'svelte';
import ToolTip from '../ToolTip.svelte';
import ProxySvgIcon from '$lib/components/ProxySvgIcon.svelte';
import { activeFormElement, activeFormElementSubscribe } from '$lib/store/activeFormElement';
export let _id = Symbol();
export let id;
export let bindTo = null;
export let defaultValue = '';
export let value = defaultValue;
export let placeholder = 'Select...';
export let required = false;
export let pageOptions = [];
export let proxyOptions = [];
export let type = 'page'; // 'page' or 'proxy'
export let toolTipText = '';
export let optional = false;
export let hidden = false;
export let size = 'normal';
export let inline = false;
export let onSelect = (value) => {};
// Get current options based on type
$: currentOptions = type === 'proxy' ? proxyOptions : pageOptions;
$: optionsArray = Array.isArray(currentOptions) ? currentOptions : Array.from(currentOptions);
let allOptions = [];
let showDropdown = false;
let inputElement;
let dropdownElement;
let isFocused = false;
// Simple function to filter options based on input
const filterOptions = (searchValue) => {
if (!searchValue) {
return [...optionsArray];
}
return optionsArray.filter(
(opt) => opt && opt.toLowerCase && opt.toLowerCase().includes(searchValue.toLowerCase())
);
};
// Track if user has typed (for filtering) vs just focused (show all)
let hasTyped = false;
// Update filtered options - show all on focus, filter only when typed
$: allOptions = hasTyped ? filterOptions(value) : [...optionsArray];
// Show dropdown when focused and there are options
const handleFocus = () => {
activeFormElement.set(id);
showDropdown = true;
isFocused = true;
hasTyped = false; // Reset typing flag to show all options
allOptions = [...optionsArray]; // Show all options on focus
};
// Handle input changes for filtering
const handleInput = (e) => {
value = e.target.value;
showDropdown = true;
hasTyped = true; // User has typed, enable filtering
allOptions = filterOptions(value);
};
// Select an option
const selectOption = (option) => {
value = option;
showDropdown = false;
isFocused = false;
hasTyped = false; // Reset typing flag after selection
onSelect(option);
};
// Close dropdown
const closeDropdown = () => {
showDropdown = false;
isFocused = false;
hasTyped = false; // Reset typing flag when closing
};
// Handle type change
const handleTypeChange = (newType) => {
type = newType;
value = ''; // reset selection when type changes
closeDropdown();
};
// Handle blur to close dropdown when tabbing out
const handleBlur = (e) => {
// Use setTimeout to allow click events on options to complete first
setTimeout(() => {
const focusedElement = document.activeElement;
const container = inputElement?.closest('.textfield-select-container');
// If focus moved outside the component, close dropdown
if (!container?.contains(focusedElement)) {
closeDropdown();
}
}, 100);
};
// Handle keyboard navigation
const handleKeyDown = (e) => {
if (!showDropdown) return;
if (e.key === 'Escape') {
e.preventDefault();
closeDropdown();
return;
}
if (e.key === 'Enter') {
e.preventDefault();
if (allOptions.length === 1) {
selectOption(allOptions[0]);
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
const firstOption = dropdownElement?.querySelector('button');
firstOption?.focus();
return;
}
};
// Handle option keyboard navigation
const handleOptionKeyDown = (e, option) => {
if (e.key === 'Tab') {
// Let tab work normally but close dropdown
closeDropdown();
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectOption(option);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
closeDropdown();
inputElement?.focus();
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
const currentButton = e.target;
const prevButton =
currentButton.parentElement?.previousElementSibling?.querySelector('button');
if (prevButton) {
prevButton.focus();
} else {
inputElement?.focus();
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
const currentButton = e.target;
const nextButton = currentButton.parentElement?.nextElementSibling?.querySelector('button');
if (nextButton) {
nextButton.focus();
}
return;
}
};
// Handle clicks outside to close dropdown
const handleOutsideClick = (e) => {
if (!showDropdown) return;
const container = inputElement?.closest('.textfield-select-container');
if (container && !container.contains(e.target)) {
closeDropdown();
}
};
// Bind to parent form element
let parentForm = null;
let parentFormResetListener = null;
onMount(() => {
value = value || defaultValue;
const unsubscribe = activeFormElementSubscribe(_id, closeDropdown);
document.addEventListener('click', handleOutsideClick);
return () => {
unsubscribe();
document.removeEventListener('click', handleOutsideClick);
};
});
afterUpdate(() => {
if (inputElement) {
bindTo = inputElement;
}
if (!parentForm && inputElement) {
parentForm = inputElement.closest('form');
if (parentForm) {
parentFormResetListener = parentForm.addEventListener('reset', (event) => {
event.preventDefault();
value = defaultValue;
});
}
}
});
onDestroy(() => {
if (parentFormResetListener && parentForm) {
parentForm.removeEventListener('reset', parentFormResetListener);
}
document.removeEventListener('click', handleOutsideClick);
});
// Generate unique IDs for accessibility
const comboboxId = id || `textfield-select-${_id.toString()}`;
const listboxId = `${comboboxId}-listbox`;
const labelId = `${comboboxId}-label`;
// Reactive statements for accessibility
$: hasValue = value && value !== '';
$: ariaExpanded = showDropdown;
</script>
<div
class="flex justify-start textfield-select-container"
class:hidden
class:flex-col={!inline}
class:flex-row={inline}
>
<label class="flex flex-col py-2 relative" class:py-2={!inline} class:pr-2={inline}>
<div class="flex items-center">
<p
id={labelId}
class="font-semibold text-slate-600 dark:text-gray-300 py-1 transition-colors duration-200"
>
<slot />
</p>
{#if toolTipText.length > 0}
<ToolTip>
{toolTipText}
</ToolTip>
{/if}
{#if optional === true}
<div
class="bg-gray-100 dark:bg-gray-700 ml-2 px-2 rounded-md transition-colors duration-200"
>
<p class="text-slate-600 dark:text-gray-300 text-xs">optional</p>
</div>
{/if}
</div>
</label>
<div class="relative">
<div
class="flex items-center relative"
class:w-28={size == 'small'}
class:w-60={size == 'normal'}
>
<input
bind:this={inputElement}
type="text"
role="combobox"
id={comboboxId}
aria-labelledby={labelId}
aria-expanded={ariaExpanded}
aria-controls={listboxId}
aria-autocomplete="list"
aria-haspopup="listbox"
bind:value
on:focus={handleFocus}
on:blur={handleBlur}
on:input={handleInput}
on:keydown={handleKeyDown}
on:click={handleFocus}
autocomplete="off"
class="w-full relative rounded-md py-2 pr-10 text-gray-600 dark:text-gray-300 border border-transparent focus:outline-none focus:border-solid focus:border focus:border-slate-400 dark:focus:border-gray-500 focus:bg-gray-100 dark:focus:bg-gray-600 bg-grayblue-light dark:bg-gray-700 font-normal cursor-pointer focus:cursor-text transition-colors duration-200"
class:pl-10={isFocused && showDropdown}
class:pl-20={!isFocused}
class:pl-4={isFocused && !showDropdown}
class:text-gray-400={!hasValue && !showDropdown}
placeholder={!hasValue && !showDropdown ? placeholder : ''}
{required}
/>
<!-- Type selector buttons inside the input on the left - hidden when focused -->
{#if !isFocused}
<div class="absolute left-1 top-1/2 transform -translate-y-1/2 flex bg-transparent">
<button
type="button"
class="w-8 h-8 text-base transition-all duration-200 rounded-lg border flex items-center justify-center {type ===
'page'
? 'border-green-500 dark:border-green-400 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300'
: 'border-gray-200 dark:border-gray-600 bg-grayblue-light dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:border-blue-300 dark:hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/30'}"
on:click={() => handleTypeChange('page')}
title="Page"
>
📄
</button>
<button
type="button"
class="w-8 h-8 text-base transition-all duration-200 rounded-lg border flex items-center justify-center ml-0.5 {type ===
'proxy'
? 'border-green-500 dark:border-green-400 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300'
: 'border-gray-200 dark:border-gray-600 bg-grayblue-light dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:border-blue-300 dark:hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/30'}"
on:click={() => handleTypeChange('proxy')}
title="Proxy"
>
<ProxySvgIcon size="w-5 h-5" />
</button>
</div>
{/if}
<!-- Search icon - visible when dropdown is open -->
{#if showDropdown}
<img
class="absolute w-4 left-3 select-none pointer-events-none z-10"
src="/search-icon.svg"
alt=""
aria-hidden="true"
/>
{/if}
<!-- Clear button for optional fields -->
{#if optional === true && hasValue}
<button
class="absolute right-10 z-10"
type="button"
aria-label="Clear selection"
on:click={(e) => {
e.stopPropagation();
value = '';
onSelect('');
inputElement?.focus();
}}
>
<img class="w-4" src="/remove-value.svg" alt="" />
</button>
{/if}
<!-- Dropdown arrow -->
<img
class="absolute pointer-events-none w-4 right-3"
class:right-12={optional === true && hasValue}
src="/arrow.svg"
alt=""
aria-hidden="true"
/>
</div>
<!-- Dropdown list -->
{#if showDropdown}
<div
bind:this={dropdownElement}
class="absolute top-10 z-50"
class:w-28={size == 'small'}
class:w-60={size == 'normal'}
>
<ul
id={listboxId}
role="listbox"
aria-labelledby={labelId}
class="bg-gray-100 dark:bg-gray-700 list-none mt-4 z-[999] rounded-md min-w-fit shadow-md border border-gray-200 dark:border-gray-600 max-h-40 overflow-y-scroll transition-colors duration-200"
>
{#if allOptions.length}
{#each allOptions as option, index}
<li role="none">
<button
id="{listboxId}-option-{index}"
role="option"
aria-selected={value === option}
class="w-full text-left bg-slate-100 dark:bg-gray-600 rounded-md text-gray-600 dark:text-gray-200 hover:bg-grayblue-dark dark:hover:bg-gray-500 hover:text-white py-2 px-2 cursor-pointer focus:bg-grayblue-dark dark:focus:bg-gray-500 focus:text-white focus:outline-none transition-colors duration-200"
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
selectOption(option);
}}
on:keydown={(e) => handleOptionKeyDown(e, option)}
on:blur={handleBlur}
>
{option}
</button>
</li>
{/each}
{:else}
<li
role="none"
class="w-full bg-slate-100 dark:bg-gray-600 rounded-md text-gray-600 dark:text-gray-300 py-2 px-2 transition-colors duration-200"
>
No {type === 'proxy' ? 'Proxies' : 'pages'} available
</li>
{/if}
</ul>
</div>
{/if}
</div>
</div>
@@ -65,6 +65,11 @@
api_senders: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
</svg>
`,
proxy: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
`
};
@@ -79,6 +84,7 @@
'/recipient/group/': 'recipient_groups',
'/domain/': 'domains_overview',
'/page/': 'pages',
'/proxy/': 'proxy',
'/asset/': 'assets',
'/email/': 'emails_overview',
'/attachment/': 'attachments',
+5 -1
View File
@@ -56,6 +56,10 @@ export const route = {
label: 'Pages',
route: '/page/'
},
proxy: {
label: 'Proxies',
route: '/proxy/'
},
campaignTemplates: {
label: 'Campaign Templates',
singleLabel: 'Templates',
@@ -109,7 +113,7 @@ export const menu = [
{
label: 'Domains',
type: 'submenu',
items: [route.domain, route.pages, route.assets]
items: [route.domain, route.pages, route.proxy, route.assets]
},
{
label: 'Emails',
@@ -18,6 +18,7 @@
import { BiMap } from '$lib/utils/maps';
import TextFieldSelect from '$lib/components/TextFieldSelect.svelte';
import Modal from '$lib/components/Modal.svelte';
import ProxySvgIcon from '$lib/components/ProxySvgIcon.svelte';
import FormGrid from '$lib/components/FormGrid.svelte';
import TableCellEmpty from '$lib/components/table/TableCellEmpty.svelte';
import BigButton from '$lib/components/BigButton.svelte';
@@ -30,10 +31,11 @@
import TableCellCheck from '$lib/components/table/TableCellCheck.svelte';
import TableDropDownEllipsis from '$lib/components/table/TableDropDownEllipsis.svelte';
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
import { page } from '$app/stores'; // Add this import at the top
import { page } from '$app/stores';
import SelectSquare from '$lib/components/SelectSquare.svelte';
import TableDropDownButton from '$lib/components/table/TableDropDownButton.svelte';
import CopyCell from '$lib/components/table/CopyCell.svelte';
import TextFieldSelectWithType from '$lib/components/form/TextFieldSelectWithType.svelte';
// services
const appStateService = AppStateService.instance;
@@ -46,8 +48,11 @@
name: null,
domain: null,
landingPage: null,
landingPageType: 'page', // 'page' or 'proxy'
beforeLandingPage: null,
beforeLandingPageType: 'page', // 'page' or 'proxy'
afterLandingPage: null,
afterLandingPageType: 'page', // 'page' or 'proxy'
afterLandingPageRedirectURL: null,
email: null,
smtpConfiguration: null,
@@ -59,9 +64,13 @@
let contextCompanyID = null;
let domainMap = new BiMap({});
let domainObjectMap = new Map(); // stores full domain objects
let beforeLandingPageMap = new BiMap({});
let landingPageMap = new BiMap({});
let afterLandingPageMap = new BiMap({});
let beforeLandingProxyMap = new BiMap({});
let landingProxyMap = new BiMap({});
let afterLandingProxyMap = new BiMap({});
let emailMap = new BiMap({});
let smtpConfigurationMap = new BiMap({});
let apiSenderMap = new BiMap({});
@@ -107,6 +116,7 @@
refreshSmtpConfigurations(),
refreshApiSenders(),
refreshPages(),
refreshProxies(),
getCampaignTemplates(),
refreshIdentifiers()
]);
@@ -123,10 +133,17 @@
});
const refreshDomains = async () => {
const domains = await fetchAllRows((options) => {
const allDomains = await fetchAllRows((options) => {
return api.domain.getAllSubset(options, contextCompanyID);
});
domainMap = BiMap.FromArrayOfObjects(domains);
// filter to only include regular domains (not proxy domains)
const regularDomains = allDomains.filter((domain) => domain.type !== 'proxy');
domainMap = BiMap.FromArrayOfObjects(regularDomains);
// store full domain objects for type access
domainObjectMap = new Map();
regularDomains.forEach((domain) => {
domainObjectMap.set(domain.id, domain);
});
};
const refreshEmails = async () => {
@@ -159,6 +176,15 @@
afterLandingPageMap = BiMap.FromArrayOfObjects(pages);
};
const refreshProxies = async () => {
const proxies = await fetchAllRows((options) => {
return api.proxy.getAllSubset(options, contextCompanyID);
});
landingProxyMap = BiMap.FromArrayOfObjects(proxies);
beforeLandingProxyMap = BiMap.FromArrayOfObjects(proxies);
afterLandingProxyMap = BiMap.FromArrayOfObjects(proxies);
};
const refreshIdentifiers = async () => {
const identifiers = await fetchAllRows((options) => {
return api.identifier.getAll(options);
@@ -271,9 +297,30 @@
emailID: emailMap.byValueOrNull(formValues.email),
smtpConfigurationID: smtpConfigurationMap.byValueOrNull(formValues.smtpConfiguration),
apiSenderID: apiSenderMap.byValueOrNull(formValues.apiSender),
landingPageID: landingPageMap.byValue(formValues.landingPage),
beforeLandingPageID: beforeLandingPageMap.byValueOrNull(formValues.beforeLandingPage),
afterLandingPageID: afterLandingPageMap.byValueOrNull(formValues.afterLandingPage),
landingPageID:
formValues.landingPageType === 'page'
? landingPageMap.byValueOrNull(formValues.landingPage)
: null,
landingProxyID:
formValues.landingPageType === 'proxy'
? landingProxyMap.byValueOrNull(formValues.landingPage)
: null,
beforeLandingPageID:
formValues.beforeLandingPageType === 'page'
? beforeLandingPageMap.byValueOrNull(formValues.beforeLandingPage)
: null,
beforeLandingProxyID:
formValues.beforeLandingPageType === 'proxy'
? beforeLandingProxyMap.byValueOrNull(formValues.beforeLandingPage)
: null,
afterLandingPageID:
formValues.afterLandingPageType === 'page'
? afterLandingPageMap.byValueOrNull(formValues.afterLandingPage)
: null,
afterLandingProxyID:
formValues.afterLandingPageType === 'proxy'
? afterLandingProxyMap.byValueOrNull(formValues.afterLandingPage)
: null,
afterLandingPageRedirectURL: formValues.afterLandingPageRedirectURL,
urlIdentifierID: identifierMap.byValueOrNull(formValues.urlIdentifier),
stateIdentifierID: identifierMap.byValueOrNull(formValues.stateIdentifier),
@@ -302,9 +349,30 @@
emailID: emailMap.byValueOrNull(formValues.email),
smtpConfigurationID: smtpConfigurationMap.byValueOrNull(formValues.smtpConfiguration),
apiSenderID: apiSenderMap.byValueOrNull(formValues.apiSender),
landingPageID: landingPageMap.byValueOrNull(formValues.landingPage),
beforeLandingPageID: beforeLandingPageMap.byValueOrNull(formValues.beforeLandingPage),
afterLandingPageID: afterLandingPageMap.byValueOrNull(formValues.afterLandingPage),
landingPageID:
formValues.landingPageType === 'page'
? landingPageMap.byValueOrNull(formValues.landingPage)
: null,
landingProxyID:
formValues.landingPageType === 'proxy'
? landingProxyMap.byValueOrNull(formValues.landingPage)
: null,
beforeLandingPageID:
formValues.beforeLandingPageType === 'page'
? beforeLandingPageMap.byValueOrNull(formValues.beforeLandingPage)
: null,
beforeLandingProxyID:
formValues.beforeLandingPageType === 'proxy'
? beforeLandingProxyMap.byValueOrNull(formValues.beforeLandingPage)
: null,
afterLandingPageID:
formValues.afterLandingPageType === 'page'
? afterLandingPageMap.byValueOrNull(formValues.afterLandingPage)
: null,
afterLandingProxyID:
formValues.afterLandingPageType === 'proxy'
? afterLandingProxyMap.byValueOrNull(formValues.afterLandingPage)
: null,
afterLandingPageRedirectURL: formValues.afterLandingPageRedirectURL,
urlIdentifierID: identifierMap.byValueOrNull(formValues.urlIdentifier),
stateIdentifierID: identifierMap.byValueOrNull(formValues.stateIdentifier),
@@ -359,8 +427,11 @@
name: null,
domain: null,
landingPage: null,
landingPageType: 'page',
beforeLandingPage: null,
beforeLandingPageType: 'page',
afterLandingPage: null,
afterLandingPageType: 'page',
afterLandingPageRedirectURL: null,
email: null,
smtpConfiguration: null,
@@ -422,9 +493,34 @@
}
formValues.domain = domainMap.byKey(template.domainID);
formValues.email = emailMap.byKey(template.emailID);
formValues.landingPage = landingPageMap.byKey(template.landingPageID);
formValues.beforeLandingPage = beforeLandingPageMap.byKey(template.beforeLandingPageID);
formValues.afterLandingPage = afterLandingPageMap.byKey(template.afterLandingPageID);
// handle landing page (page or proxy)
if (template.landingPageID) {
formValues.landingPage = landingPageMap.byKey(template.landingPageID);
formValues.landingPageType = 'page';
} else if (template.landingProxyID) {
formValues.landingPage = landingProxyMap.byKey(template.landingProxyID);
formValues.landingPageType = 'proxy';
}
// handle before landing page (page or proxy)
if (template.beforeLandingPageID) {
formValues.beforeLandingPage = beforeLandingPageMap.byKey(template.beforeLandingPageID);
formValues.beforeLandingPageType = 'page';
} else if (template.beforeLandingProxyID) {
formValues.beforeLandingPage = beforeLandingProxyMap.byKey(template.beforeLandingProxyID);
formValues.beforeLandingPageType = 'proxy';
}
// handle after landing page (page or proxy)
if (template.afterLandingPageID) {
formValues.afterLandingPage = afterLandingPageMap.byKey(template.afterLandingPageID);
formValues.afterLandingPageType = 'page';
} else if (template.afterLandingProxyID) {
formValues.afterLandingPage = afterLandingProxyMap.byKey(template.afterLandingProxyID);
formValues.afterLandingPageType = 'proxy';
}
formValues.afterLandingPageRedirectURL = template.afterLandingPageRedirectURL;
formValues.urlIdentifier = identifierMap.byKey(template.urlIdentifierID);
formValues.stateIdentifier = identifierMap.byKey(template.stateIdentifierID);
@@ -515,6 +611,13 @@
<a href={`/page/?edit=${template.beforeLandingPageID}`} class="block w-full py-1">
{beforeLandingPageMap.byKey(template.beforeLandingPageID)}
</a>
{:else if template.beforeLandingProxyID}
<a href={`/proxy/?edit=${template.beforeLandingProxyID}`} class="block w-full py-1">
<span class="flex items-center gap-1">
<ProxySvgIcon size="w-4 h-4" />
{beforeLandingProxyMap.byKey(template.beforeLandingProxyID)}
</span>
</a>
{/if}
</TableCell>
<TableCell>
@@ -522,6 +625,13 @@
<a href={`/page/?edit=${template.landingPageID}`} class="block w-full py-1">
{landingPageMap.byKey(template.landingPageID)}
</a>
{:else if template.landingProxyID}
<a href={`/proxy/?edit=${template.landingProxyID}`} class="block w-full py-1">
<span class="flex items-center gap-1">
<ProxySvgIcon size="w-4 h-4" />
{landingProxyMap.byKey(template.landingProxyID)}
</span>
</a>
{/if}
</TableCell>
<TableCell>
@@ -529,6 +639,13 @@
<a href={`/page/?edit=${template.afterLandingPageID}`} class="block w-full py-1">
{afterLandingPageMap.byKey(template.afterLandingPageID)}
</a>
{:else if template.afterLandingProxyID}
<a href={`/proxy/?edit=${template.afterLandingProxyID}`} class="block w-full py-1">
<span class="flex items-center gap-1">
<ProxySvgIcon size="w-4 h-4" />
{afterLandingProxyMap.byKey(template.afterLandingProxyID)}
</span>
</a>
{/if}
</TableCell>
<TableCell>
@@ -781,31 +898,37 @@ Simulation URLs to allow:\n${allowListingData.simulationUrl}\n
<div class="w-full">
<h3 class="text-base font-medium text-pc-darkblue dark:text-white mb-3">Page Flow</h3>
<div class="grid grid-cols-1 md:grid-cols-5 gap-6">
<div class="md:col-span-2 flex flex-col space-y-4">
<div>
<TextFieldSelect
id="beforeLandingPage"
bind:value={formValues.beforeLandingPage}
options={beforeLandingPageMap.values()}
optional>Before Landing Page</TextFieldSelect
>
</div>
<div>
<TextFieldSelect
id="landingPage"
required
bind:value={formValues.landingPage}
options={landingPageMap.values()}>Landing Page</TextFieldSelect
>
</div>
<div>
<TextFieldSelect
id="afterLandingPage"
bind:value={formValues.afterLandingPage}
options={afterLandingPageMap.values()}
optional>After Landing Page</TextFieldSelect
>
</div>
<div class="md:col-span-2 flex flex-col space-y-6">
<!-- Before Landing Page -->
<TextFieldSelectWithType
id="beforeLandingPage"
bind:value={formValues.beforeLandingPage}
bind:type={formValues.beforeLandingPageType}
pageOptions={beforeLandingPageMap.values()}
proxyOptions={beforeLandingProxyMap.values()}
optional>Before Landing</TextFieldSelectWithType
>
<!-- Landing Page -->
<TextFieldSelectWithType
id="landingPage"
bind:value={formValues.landingPage}
bind:type={formValues.landingPageType}
pageOptions={landingPageMap.values()}
proxyOptions={landingProxyMap.values()}
required>Landing</TextFieldSelectWithType
>
<!-- After Landing Page -->
<TextFieldSelectWithType
id="afterLandingPage"
bind:value={formValues.afterLandingPage}
bind:type={formValues.afterLandingPageType}
pageOptions={afterLandingPageMap.values()}
proxyOptions={afterLandingProxyMap.values()}
optional>After Landing</TextFieldSelectWithType
>
<div>
<TextField
bind:value={formValues.afterLandingPageRedirectURL}
@@ -833,7 +956,11 @@ Simulation URLs to allow:\n${allowListingData.simulationUrl}\n
>
</div>
<div class="flex-1">
<p class="text-xs font-medium">Before Landing Page</p>
<p class="text-xs font-medium">
Before Landing {formValues.beforeLandingPageType === 'proxy'
? 'Proxy'
: 'Page'}
</p>
<p class="text-xs text-gray-500 truncate max-w-[180px]">
{formValues.beforeLandingPage || 'Not selected'}
</p>
@@ -853,7 +980,9 @@ Simulation URLs to allow:\n${allowListingData.simulationUrl}\n
<span class="text-xl text-blue-600">2</span>
</div>
<div class="flex-1">
<p class="text-xs font-medium">Landing Page</p>
<p class="text-xs font-medium">
Landing {formValues.landingPageType === 'proxy' ? 'Proxy' : 'Page'}
</p>
<p class="text-xs text-gray-500 truncate max-w-[180px]">
{formValues.landingPage || 'Required'}
</p>
@@ -877,7 +1006,9 @@ Simulation URLs to allow:\n${allowListingData.simulationUrl}\n
>
</div>
<div class="flex-1">
<p class="text-xs font-medium">After Landing Page</p>
<p class="text-xs font-medium">
After Landing {formValues.afterLandingPageType === 'proxy' ? 'Proxy' : 'Page'}
</p>
<p class="text-xs text-gray-500 truncate max-w-[180px]">
{formValues.afterLandingPage || 'Not selected'}
</p>
+288 -17
View File
@@ -8,6 +8,7 @@
import { BiMap } from '$lib/utils/maps';
import { defaultOptions, fetchAllRows } from '$lib/utils/api-utils';
import { AppStateService } from '$lib/service/appState';
import ProxySvgIcon from '$lib/components/ProxySvgIcon.svelte';
import TableRow from '$lib/components/table/TableRow.svelte';
import TableCell from '$lib/components/table/TableCell.svelte';
import TableCellLink from '$lib/components/table/TableCellLink.svelte';
@@ -36,6 +37,11 @@
import Alert from '$lib/components/Alert.svelte';
import EventTimeline from '$lib/components/EventTimeline.svelte';
import CellCopy from '$lib/components/table/CopyCell.svelte';
import Button from '$lib/components/Button.svelte';
import FormGrid from '$lib/components/FormGrid.svelte';
import FormFooter from '$lib/components/FormFooter.svelte';
import FormColumns from '$lib/components/FormColumns.svelte';
import FormColumn from '$lib/components/FormColumn.svelte';
import EventName from '$lib/components/table/EventName.svelte';
import { goto } from '$app/navigation';
import { globalButtonDisabledAttributes } from '$lib/utils/form';
@@ -124,6 +130,8 @@
let isCloseModalVisible = false;
let isAnonymizeModalVisible = false;
let isSendMessageModalVisible = false;
let isStorageAceModalVisible = false;
let storedCookieData = '';
let sendMessageRecipient = null;
let lastPoll3399Nano = '';
@@ -560,6 +568,48 @@
isAnonymizeModalVisible = false;
};
const closeStorageAceModal = () => {
isStorageAceModalVisible = false;
storedCookieData = '';
};
const onStorageAceModalOk = () => {
closeStorageAceModal();
};
/** @param {string} eventData @param {string} eventName */
const onClickCopyEventData = async (eventData, eventName) => {
try {
// remove the cookie emoji prefix before copying
const dataWithoutEmoji = eventData.startsWith('🍪 ') ? eventData.substring(2) : eventData;
await navigator.clipboard.writeText(dataWithoutEmoji);
if (eventName === 'campaign_recipient_submitted_data' && eventData.startsWith('🍪')) {
storedCookieData = eventData;
isStorageAceModalVisible = true;
}
addToast('Copied to clipboard', 'Success');
} catch (e) {
addToast('Failed to copy data to clipboard', 'Error');
console.error('failed to copy data to clipboard', e);
}
};
const onClickCopyCookies = async () => {
try {
// remove the cookie emoji prefix before copying
const dataWithoutEmoji = storedCookieData.startsWith('🍪 ')
? storedCookieData.substring(2)
: storedCookieData;
await navigator.clipboard.writeText(dataWithoutEmoji);
addToast('Copied to clipboard', 'Success');
} catch (e) {
addToast('Failed to copy cookie data', 'Error');
console.error('failed to copy cookie data', e);
}
};
const onConfirmCloseCampaign = async (a) => {
let res;
try {
@@ -790,6 +840,89 @@
event.target.value = '';
}
};
// helper function to format cookie capture data
const formatEventData = (eventData, eventName) => {
if (!eventData || eventName !== 'campaign_recipient_submitted_data') {
return eventData;
}
try {
// parse the event data as JSON
const parsedData = JSON.parse(eventData);
// check if it's the new cookie bundle format
if (parsedData.capture_type === 'cookie' && parsedData.cookies) {
const cookies = [];
// iterate through each captured cookie
for (const [captureName, cookieData] of Object.entries(parsedData.cookies)) {
// convert SameSite attribute to browser extension format
let sameSite = 'no_restriction';
if (cookieData.sameSite) {
switch (cookieData.sameSite.toLowerCase()) {
case 'strict':
sameSite = 'strict';
break;
case 'lax':
sameSite = 'lax';
break;
case 'none':
sameSite = 'no_restriction';
break;
default:
sameSite = 'no_restriction';
}
}
// determine if this is a host-only cookie
const domain = cookieData.domain || '';
const hostOnly = domain && !domain.startsWith('.');
// convert to browser extension compatible format
const browserCookie = {
domain: domain,
hostOnly: hostOnly,
httpOnly: cookieData.httpOnly === 'true',
name: cookieData.name || '',
path: cookieData.path || '/',
sameSite: sameSite,
secure: cookieData.secure === 'true',
session: !cookieData.expires && !cookieData.maxAge, // session cookie if no expiration
storeId: '1',
value: cookieData.value || ''
};
// handle expiration date
if (cookieData.expires) {
const expireDate = new Date(cookieData.expires);
if (!isNaN(expireDate.getTime())) {
browserCookie.expirationDate = expireDate.getTime() / 1000;
browserCookie.session = false;
}
} else if (cookieData.maxAge) {
// handle maxAge if present
const maxAgeSeconds = parseInt(cookieData.maxAge);
if (!isNaN(maxAgeSeconds)) {
browserCookie.expirationDate = Date.now() / 1000 + maxAgeSeconds;
browserCookie.session = false;
}
}
cookies.push(browserCookie);
}
// return as array format for browser import with cookie emoji
return '🍪 ' + JSON.stringify(cookies, null, 2);
}
// for other submitted data, return as is
return eventData;
} catch (e) {
// if not valid JSON, return as is
return eventData;
}
};
</script>
<HeadTitle title="Campaign {campaign.name ? ` - ${campaign.name}` : ''}" />
@@ -1386,7 +1519,23 @@
<EventName eventName={campaign.eventTypesIDToNameMap[event.eventID]} />
</TableCell>
<TableCell>
<CellCopy text={event.data} />
{#if campaign.eventTypesIDToNameMap[event.eventID] === 'campaign_recipient_submitted_data' && formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID]).startsWith('🍪')}
<button
class="hover:bg-gray-100 dark:hover:bg-gray-700 px-2 py-1 rounded-md transition-colors w-full text-left text-ellipsis overflow-hidden text-gray-900 dark:text-gray-100"
title={formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID])}
on:click={() =>
onClickCopyEventData(
formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID]),
campaign.eventTypesIDToNameMap[event.eventID]
)}
>
{formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID])}
</button>
{:else}
<CellCopy
text={formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID])}
/>
{/if}
</TableCell>
<TableCell>
<CellCopy text={event.userAgent} />
@@ -1539,12 +1688,23 @@
<EventName eventName={campaign.eventTypesIDToNameMap[event.eventID]} />
</TableCell>
<TableCell>
<button
class="hover:bg-gray-100 px-2 py-1 rounded-md transition-colors w-full text-left"
on:click={() => onClickCopy(event.data)}
>
{event.data}
</button>
{#if campaign.eventTypesIDToNameMap[event.eventID] === 'campaign_recipient_submitted_data' && formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID]).startsWith('🍪')}
<button
class="hover:bg-gray-100 dark:hover:bg-gray-700 px-2 py-1 rounded-md transition-colors w-full text-left text-ellipsis overflow-hidden text-gray-900 dark:text-gray-100"
title={formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID])}
on:click={() =>
onClickCopyEventData(
formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID]),
campaign.eventTypesIDToNameMap[event.eventID]
)}
>
{formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID])}
</button>
{:else}
<CellCopy
text={formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID])}
/>
{/if}
</TableCell>
<TableCell>
<button
@@ -1594,7 +1754,7 @@
</div>
<!-- Only show arrow if there's a destination -->
{#if template.beforeLandingPage || template.landingPage}
{#if template.beforeLandingPage || template.beforeLandingProxy || template.landingPage || template.landingProxy}
<div class="mx-2"></div>
{/if}
@@ -1604,7 +1764,19 @@
<div class="font-medium text-gray-800 dark:text-white">Before Landing</div>
</div>
<!-- Only show arrow if there's a next step -->
{#if template.landingPage}
{#if template.landingPage || template.landingProxy}
<div class="mx-2"></div>
{/if}
{:else if template.beforeLandingProxy}
<div class="text-center px-3 py-2 bg-pc-lightblue dark:bg-blue-600 rounded">
<div
class="font-medium text-gray-800 dark:text-white flex items-center justify-center gap-1"
>
<ProxySvgIcon size="w-4 h-4" /> Before
</div>
</div>
<!-- Only show arrow if there's a next step -->
{#if template.landingPage || template.landingProxy}
<div class="mx-2"></div>
{/if}
{/if}
@@ -1615,7 +1787,19 @@
<div class="font-medium text-gray-800 dark:text-white">Main Landing</div>
</div>
<!-- Only show arrow if there's a next step -->
{#if template.afterLandingPage || template.afterLandingPageRedirectURL}
{#if template.afterLandingPage || template.afterLandingProxy || template.afterLandingPageRedirectURL}
<div class="mx-2"></div>
{/if}
{:else if template.landingProxy}
<div class="text-center px-3 py-2 bg-pc-lightblue dark:bg-blue-600 rounded">
<div
class="font-medium text-gray-800 dark:text-white flex items-center justify-center gap-1"
>
<ProxySvgIcon size="w-4 h-4" /> Main
</div>
</div>
<!-- Only show arrow if there's a next step -->
{#if template.afterLandingPage || template.afterLandingProxy || template.afterLandingPageRedirectURL}
<div class="mx-2"></div>
{/if}
{/if}
@@ -1625,6 +1809,14 @@
<div class="text-center px-3 py-2 bg-pc-lightblue dark:bg-blue-600 rounded">
<div class="font-medium text-gray-800 dark:text-white">After Landing</div>
</div>
{:else if template.afterLandingProxy}
<div class="text-center px-3 py-2 bg-pc-lightblue dark:bg-blue-600 rounded">
<div
class="font-medium text-gray-800 dark:text-white flex items-center justify-center gap-1"
>
<ProxySvgIcon size="w-4 h-4" /> After
</div>
</div>
{/if}
{#if template.afterLandingPageRedirectURL}
<div class="mx-2"></div>
@@ -1667,17 +1859,40 @@
</span>
<span class="text-grayblue-dark font-medium">Before Page:</span>
<span class="text-pc-darkblue dark:text-white"
>{template.beforeLandingPage?.name ?? ''}</span
>
<span class="text-pc-darkblue dark:text-white">
{#if template.beforeLandingPage}
{template.beforeLandingPage.name}
{:else if template.beforeLandingProxy}
<span class="flex items-center gap-1">
<ProxySvgIcon size="w-4 h-4" />
{template.beforeLandingProxy.name}
</span>
{/if}
</span>
<span class="text-grayblue-dark font-medium">Main Page:</span>
<span class="text-pc-darkblue dark:text-white">{template.landingPage?.name ?? ''}</span>
<span class="text-pc-darkblue dark:text-white">
{#if template.landingPage}
{template.landingPage.name}
{:else if template.landingProxy}
<span class="flex items-center gap-1">
<ProxySvgIcon size="w-4 h-4" />
{template.landingProxy.name}
</span>
{/if}
</span>
<span class="text-grayblue-dark font-medium">After Page:</span>
<span class="text-pc-darkblue dark:text-white"
>{template.afterLandingPage?.name ?? ''}</span
>
<span class="text-pc-darkblue dark:text-white">
{#if template.afterLandingPage}
{template.afterLandingPage.name}
{:else if template.afterLandingProxy}
<span class="flex items-center gap-1">
<ProxySvgIcon size="w-4 h-4" />
{template.afterLandingProxy.name}
</span>
{/if}
</span>
<span class="text-grayblue-dark font-medium">Redirect URL:</span>
<span class="text-pc-darkblue dark:text-white"
@@ -1845,4 +2060,60 @@
{/if}
</div>
</Alert>
<Modal
headerText={'Cookies captured'}
visible={isStorageAceModalVisible}
onClose={closeStorageAceModal}
>
<div class="mt-4">
<!-- Introduction Section -->
<div>
<h3 class="text-xl font-semibold text-gray-700">Import cookie</h3>
<p class="text-gray-600 mb-4">
Cookies can be imported using the <a
href="https://chromewebstore.google.com/detail/storageace/cpbgcbmddckpmhfbdckeolkkhkjjmplo"
target="_blank"
class="text-blue-600 dark:text-white hover:underline">StorageAce</a
> extension.
</p>
</div>
<!-- Copy Section -->
<div class="bg-gray-50 dark:bg-gray-700 p-4 rounded-md">
<button
class="text-blue-600 dark:text-white hover:text-blue-800 dark:hover:text-gray-300 font-medium inline-flex items-center gap-2"
on:click={onClickCopyCookies}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
></path>
</svg>
Copy cookies
</button>
</div>
</div>
<FormGrid on:submit={onStorageAceModalOk}>
<FormColumns>
<FormColumn>
<!-- Empty form column for structure -->
</FormColumn>
</FormColumns>
<div
class="py-4 row-span-2 col-start-1 col-span-3 border-t-2 border-gray-200 dark:border-gray-700 w-full flex flex-row justify-center items-center sm:justify-center md:justify-center lg:justify-end xl:justify-end 2xl:justify-end bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 transition-colors duration-200"
>
<button
type="button"
on:click={closeStorageAceModal}
class="bg-blue-600 hover:bg-blue-500 dark:bg-blue-500 dark:hover:bg-blue-400 text-sm uppercase font-bold px-4 py-2 text-white rounded-md transition-colors duration-200"
>
Close
</button>
</div>
</FormGrid>
</Modal>
</main>
+207 -78
View File
@@ -18,6 +18,7 @@
import { AppStateService } from '$lib/service/appState';
import TableCellAction from '$lib/components/table/TableCellAction.svelte';
import TableCellEmpty from '$lib/components/table/TableCellEmpty.svelte';
import ProxySvgIcon from '$lib/components/ProxySvgIcon.svelte';
import FormGrid from '$lib/components/FormGrid.svelte';
import Modal from '$lib/components/Modal.svelte';
import TableCellCheck from '$lib/components/table/TableCellCheck.svelte';
@@ -54,8 +55,9 @@
ownManagedTLSPem: null,
hostWebsite: true,
pageContent: '', // default value
pageNotFoundContent: '404 page not found', // default value
redirectURL: ''
pageNotFoundContent: '', // default value
redirectURL: '',
staticContent: ''
};
let isDeleteAlertVisible = false;
@@ -67,6 +69,10 @@
let defaultValues = {
...formValues
};
let currentDomain = null; // store current domain for proxy info
$: isProxyDomain = currentDomain?.type === 'proxy';
$: isRegularDomain = !isProxyDomain;
let contextCompanyID = null;
let domains = [];
let modalError = '';
@@ -78,6 +84,7 @@
let isUpdateNotFoundModalVisible = false;
let isCopyContentModalVisible = false;
let isDomainTableLoading = false;
// @type {null|'create'|'update'}
let modalMode = null;
let modalText = '';
@@ -169,23 +176,44 @@
try {
isSubmitting = true;
updateContentError = '';
// clear site contents if not hosting a website
if (!formValues.hostWebsite) {
// clear site contents if not hosting a website or if proxy domain
if (!formValues.hostWebsite || isProxyDomain) {
formValues.pageContent = '';
formValues.pageNotFoundContent = '';
}
const res = await api.domain.update({
id: formValues.id,
managedTLS: formValues.managedTLS,
ownManagedTLS: formValues.ownManagedTLS,
ownManagedTLSKey: formValues.ownManagedTLSKey,
ownManagedTLSPem: formValues.ownManagedTLSPem,
hostWebsite: formValues.hostWebsite,
pageContent: formValues.pageContent,
pageNotFoundContent: formValues.pageNotFoundContent,
redirectURL: formValues.redirectURL,
companyID: contextCompanyID
});
// prepare complete update data
let updateData;
if (isProxyDomain) {
// for proxy domains, only send TLS-related fields
updateData = {
id: formValues.id,
managedTLS: formValues.managedTLS,
ownManagedTLS: formValues.ownManagedTLS,
ownManagedTLSKey: formValues.ownManagedTLSKey,
ownManagedTLSPem: formValues.ownManagedTLSPem,
companyID: contextCompanyID
};
} else {
// for regular domains, send all fields
updateData = {
id: formValues.id,
type: 'regular',
proxyTargetDomain: '',
managedTLS: formValues.managedTLS,
ownManagedTLS: formValues.ownManagedTLS,
ownManagedTLSKey: formValues.ownManagedTLSKey,
ownManagedTLSPem: formValues.ownManagedTLSPem,
hostWebsite: formValues.hostWebsite,
pageContent: formValues.pageContent,
pageNotFoundContent: formValues.pageNotFoundContent,
redirectURL: formValues.redirectURL,
companyID: contextCompanyID
};
}
// @ts-ignore
const res = await api.domain.update(updateData);
if (!res.success) {
updateContentError = res.error;
return;
@@ -245,13 +273,15 @@
const onClickCreate = async () => {
modalError = '';
try {
// clear site contents if not hosting a website
if (!formValues.hostWebsite) {
// clear site contents if not hosting a website or if proxy domain
if (!formValues.hostWebsite || isProxyDomain) {
formValues.pageContent = '';
formValues.pageNotFoundContent = '';
}
const res = await api.domain.create({
name: formValues.name,
type: 'regular',
proxyTargetDomain: '',
managedTLS: formValues.managedTLS,
ownManagedTLS: formValues.ownManagedTLS,
ownManagedTLSKey: formValues.ownManagedTLSKey,
@@ -277,24 +307,45 @@
const onClickUpdate = async () => {
modalError = '';
// clear site contents if not hosting a website
if (!formValues.hostWebsite) {
// clear site contents if not hosting a website or if proxy domain
if (!formValues.hostWebsite || isProxyDomain) {
formValues.pageContent = '';
formValues.pageNotFoundContent = '';
}
try {
const res = await api.domain.update({
id: formValues.id,
managedTLS: formValues.managedTLS,
ownManagedTLS: formValues.ownManagedTLS,
ownManagedTLSKey: formValues.ownManagedTLSKey,
ownManagedTLSPem: formValues.ownManagedTLSPem,
hostWebsite: formValues.hostWebsite,
pageContent: formValues.pageContent,
pageNotFoundContent: formValues.pageNotFoundContent,
redirectURL: formValues.redirectURL,
companyID: contextCompanyID
});
// prepare complete update data
let updateData;
if (isProxyDomain) {
// for proxy domains, only send TLS-related fields
updateData = {
id: formValues.id,
managedTLS: formValues.managedTLS,
ownManagedTLS: formValues.ownManagedTLS,
ownManagedTLSKey: formValues.ownManagedTLSKey,
ownManagedTLSPem: formValues.ownManagedTLSPem,
companyID: contextCompanyID
};
} else {
// for regular domains, send all fields
updateData = {
id: formValues.id,
type: 'regular',
proxyTargetDomain: '',
managedTLS: formValues.managedTLS,
ownManagedTLS: formValues.ownManagedTLS,
ownManagedTLSKey: formValues.ownManagedTLSKey,
ownManagedTLSPem: formValues.ownManagedTLSPem,
hostWebsite: formValues.hostWebsite,
pageContent: formValues.pageContent,
pageNotFoundContent: formValues.pageNotFoundContent,
redirectURL: formValues.redirectURL,
companyID: contextCompanyID
};
}
// @ts-ignore
const res = await api.domain.update(updateData);
if (!res.success) {
modalError = res.error;
return;
@@ -349,6 +400,12 @@
showIsLoading();
try {
const domain = await getDomain(id);
// prevent opening modal for proxy domains (except for TLS settings)
if (domain.type === 'proxy') {
// Allow opening for TLS settings only
}
formValues = {
id: domain.id,
name: domain.name,
@@ -362,6 +419,10 @@
redirectURL: domain.redirectURL,
staticContent: domain.staticContent
};
// Store domain object for proxy info display
currentDomain = domain;
const r = globalButtonDisabledAttributes(domain, contextCompanyID);
if (r.disabled) {
hideIsLoading();
@@ -383,13 +444,22 @@
const openUpdateContentModal = async (id) => {
modalMode = 'update';
showIsLoading();
try {
const domain = await getDomain(id);
// prevent opening modal for proxy domains
if (domain.type === 'proxy') {
addToast('Proxy domains cannot be edited - managed through proxy configuration', 'Error');
hideIsLoading();
return;
}
assignDomainValues(domain);
isUpdateContentModalVisible = true;
} catch (e) {
addToast('Failed to load domain', 'Error');
console.error('failed to load domain', e);
console.error('failed to get domain', e);
} finally {
hideIsLoading();
}
@@ -426,6 +496,8 @@
redirectURL: domain.redirectURL,
staticContent: domain.staticContent
};
// Store domain object for proxy info display
currentDomain = domain;
};
const closeAllModals = () => {
@@ -441,7 +513,7 @@
if (contentNotFoundForm) {
contentNotFoundForm.reset();
}
isModalVisible = false;
// reset content
formValues = {
id: null,
name: null,
@@ -451,12 +523,15 @@
ownManagedTLSPem: null,
hostWebsite: true,
pageContent: '', // default value
pageNotFoundContent: '404 page not found', // default value
redirectURL: ''
pageNotFoundContent: '', // default value
redirectURL: '',
staticContent: ''
};
currentDomain = null;
isModalVisible = false;
isUpdateNotFoundModalVisible = false;
isUpdateContentModalVisible = false;
isUpdateNotFoundModalVisible = false;
isCopyContentModalVisible = false;
};
@@ -468,6 +543,14 @@
showIsLoading();
try {
const domain = await getDomain(id);
// prevent opening modal for proxy domains
if (domain.type === 'proxy') {
addToast('Proxy domains cannot be edited - managed through proxy configuration', 'Error');
hideIsLoading();
return;
}
formValues = {
id: domain.id,
name: domain.name,
@@ -478,18 +561,28 @@
hostWebsite: domain.hostWebsite,
pageContent: domain.pageContent,
pageNotFoundContent: domain.pageNotFoundContent,
redirectURL: domain.redirectURL
redirectURL: domain.redirectURL,
staticContent: domain.staticContent
};
isUpdateNotFoundModalVisible = true;
} catch (e) {
addToast('Failed to load domain', 'Error');
console.error('failed to load domain', e);
console.error('failed to get domain', e);
} finally {
hideIsLoading();
}
};
const openDeleteAlert = async (domain) => {
// prevent deletion of proxy domains
if (domain.type === 'proxy') {
addToast(
'Proxy domains can only be deleted by deleting the associated proxy configuration',
'Error'
);
return;
}
isDeleteAlertVisible = true;
deleteValues.id = domain.id;
deleteValues.name = domain.name;
@@ -522,9 +615,11 @@
{ column: 'Hosting website', size: 'small', alignText: 'center' },
{ column: 'Redirects', size: 'small', alignText: 'center' },
{ column: 'Managed TLS', size: 'small', alignText: 'center' },
{ column: 'Custom Certificates', size: 'small', alignText: 'center' }
{ column: 'Custom Certificates', size: 'small', alignText: 'center' },
{ column: 'Type', size: 'small', alignText: 'center' },
{ column: 'Target Domain', size: 'small' }
]}
sortable={['Name', 'Hosting website', 'Redirects']}
sortable={['Name', 'Hosting website', 'Redirects', 'Type']}
hasData={!!domains.length}
plural="domains"
pagination={tableURLParams}
@@ -541,13 +636,26 @@
title={domain.name}
class="block w-full py-1 text-left"
>
{domain.name}
{#if domain.type === 'proxy'}<ProxySvgIcon size="w-4 h-4" className="inline mr-1" />
{/if}{domain.name}
</button>
</TableCell>
<TableCellCheck value={domain.hostWebsite} />
<TableCellCheck value={!!domain.redirectURL} />
<TableCellCheck value={domain.managedTLS} />
<TableCellCheck value={domain.ownManagedTLS} />
<TableCell>
<div class="flex justify-center">
<span title={domain.type === 'proxy' ? 'Proxy Domain' : 'Regular Domain'}>
{#if domain.type === 'proxy'}
<ProxySvgIcon size="w-5 h-5" />
{:else}
📄
{/if}
</span>
</div>
</TableCell>
<TableCell>{domain.type === 'proxy' ? domain.proxyTargetDomain : ''}</TableCell>
<TableCellEmpty />
<TableCellAction>
<TableDropDownEllipsis>
@@ -562,21 +670,39 @@
on:click={() => openUpdateModal(domain.id)}
{...globalButtonDisabledAttributes(domain, contextCompanyID)}
/>
<TableUpdateButton
name={'Update page'}
on:click={() => openUpdateContentModal(domain.id)}
{...globalButtonDisabledAttributes(domain, contextCompanyID)}
/>
<TableUpdateButton
name={'Update 404 page'}
on:click={() => openUpdateNotFoundContentModal(domain.id)}
{...globalButtonDisabledAttributes(domain, contextCompanyID)}
/>
<TableCopyButton title={'Copy'} on:click={() => openCopyModal(domain.id)} />
<TableDeleteButton
on:click={() => openDeleteAlert(domain)}
{...globalButtonDisabledAttributes(domain, contextCompanyID)}
></TableDeleteButton>
{#if domain.type !== 'proxy'}
<TableUpdateButton
name={'Update page'}
on:click={() => openUpdateContentModal(domain.id)}
{...globalButtonDisabledAttributes(domain, contextCompanyID)}
/>
<TableUpdateButton
name={'Update 404 page'}
on:click={() => openUpdateNotFoundContentModal(domain.id)}
{...globalButtonDisabledAttributes(domain, contextCompanyID)}
/>
<TableCopyButton title={'Copy'} on:click={() => openCopyModal(domain.id)} />
<TableDeleteButton
on:click={() => openDeleteAlert(domain)}
{...globalButtonDisabledAttributes(domain, contextCompanyID)}
></TableDeleteButton>
{:else}
<TableUpdateButton
name={'Update page'}
disabled={true}
title="Proxy domains can only be edited through proxy configuration"
/>
<TableUpdateButton
name={'Update 404 page'}
disabled={true}
title="Proxy domains can only be edited through proxy configuration"
/>
<TableCopyButton disabled={true} title="Proxy domains cannot be copied" />
<TableDeleteButton
disabled={true}
title="Proxy domains can only be deleted by deleting the proxy"
></TableDeleteButton>
{/if}
<TableDropDownButton name={'Assets'} on:click={() => gotoDomainAssets(domain.name)} />
</TableDropDownEllipsis>
</TableCellAction>
@@ -608,34 +734,36 @@
placeholder="example.com">Domain</TextField
>
<SelectSquare
label="Website Hosting"
options={[
{ value: true, label: 'Host Website' },
{ value: false, label: 'Redirect Only' }
]}
bind:value={formValues.hostWebsite}
/>
{#if !isProxyDomain}
<SelectSquare
label="Website Hosting"
options={[
{ value: true, label: 'Host Website' },
{ value: false, label: 'Redirect Only' }
]}
bind:value={formValues.hostWebsite}
/>
{#if !formValues.hostWebsite}
<TextField
bind:value={formValues.redirectURL}
optional
type="url"
minLength={8}
maxLength={1024}
placeholder="https://example.com"
toolTipText="Redirect to another website when visiting domain &#13 (except for landing page or asset)"
>Redirect URL</TextField
>
{#if !formValues.hostWebsite}
<TextField
bind:value={formValues.redirectURL}
optional
type="url"
minLength={8}
maxLength={1024}
placeholder="https://example.com"
toolTipText="Redirect to another website when visiting domain &#13 (except for landing page or asset)"
>Redirect URL</TextField
>
{/if}
{/if}
</div>
</div>
<!-- SSL Configuration Section -->
<!-- TLS Configuration Section -->
<div class="pt-4 pb-2 w-full">
<h3 class="text-base font-medium text-pc-darkblue dark:text-white mb-3">
SSL Configuration
TLS Configuration
</h3>
<div class="space-y-6">
<SelectSquare
@@ -678,6 +806,7 @@
{/if}
</div>
</div>
<FormError message={modalError} />
</FormColumn>
</FormColumns>
+216 -20
View File
@@ -18,6 +18,7 @@
import TableCellAction from '$lib/components/table/TableCellAction.svelte';
import Modal from '$lib/components/Modal.svelte';
import FormGrid from '$lib/components/FormGrid.svelte';
import ProxySvgIcon from '$lib/components/ProxySvgIcon.svelte';
import BigButton from '$lib/components/BigButton.svelte';
import FormColumns from '$lib/components/FormColumns.svelte';
import FormColumn from '$lib/components/FormColumn.svelte';
@@ -33,6 +34,7 @@
import { fetchAllRows } from '$lib/utils/api-utils';
import { BiMap } from '$lib/utils/maps';
import AutoRefresh from '$lib/components/AutoRefresh.svelte';
import SimpleCodeEditor from '$lib/components/editor/SimpleCodeEditor.svelte';
// services
const appStateService = AppStateService.instance;
@@ -42,7 +44,10 @@
let formValues = {
id: null,
name: null,
content: null
content: null,
type: 'regular',
targetURL: null,
proxyConfig: null
};
let isSubmitting = false;
@@ -63,6 +68,54 @@
name: null
};
// proxy example configuration - simplified to only capture and replacement rules
const proxyExample = `capture:
- name: 'login credentials'
method: 'POST' # optional, default GET
path: '/login' # regex path pattern - matches /login exactly
find: 'username=([^&]+)&password=([^&]+)' # REQUIRED - regex pattern to capture data
from: 'request_body' # where to capture from: request_body, request_header, response_body, response_header, any
# required: true # default - all captures are required unless explicitly set to false
- name: 'has completed login'
method: 'GET'
path: '/secure' # navigation tracking - just checks if user visited this path
# no find pattern needed for path-based navigation tracking
# required: true # default - user must visit /secure before campaign progresses
- name: 'form submission'
method: 'POST'
path: '/submit-data' # tracks POST requests to this endpoint
# no find pattern needed - just tracking that the form was submitted
- name: 'profile update'
method: 'PUT'
path: '/api/profile' # tracks PUT requests for profile updates
# navigation tracking works with any HTTP method
- name: 'api tokens'
path: '/api/v\\d+/auth.*' # regex - matches /api/v1/auth, /api/v2/auth/token, etc.
find: 'token=([a-zA-Z0-9]+)' # REQUIRED - all captures must have a find pattern
from: 'response_body'
- name: 'optional tracking data'
path: '^/dashboard' # regex - matches paths starting with /dashboard
find: 'session_id=([a-f0-9]+)' # REQUIRED - find pattern is mandatory
from: 'response_header'
required: false # explicitly mark as optional - campaign will progress without this
replace:
- name: 'replace logo'
find: 'https://target\\.example\\.com/logo\\.png'
replace: 'https://evil.domain.com/assets/logo.png'
- name: 'replace links'
find: 'href="([^"]*target\\.example\\.com[^"]*)"'
replace: 'href="https://evil.domain.com$1"'`;
$: isRegularPage = formValues.type === 'regular';
$: isProxyPage = formValues.type === 'proxy';
$: {
modalText = getModalText('page', modalMode);
}
@@ -156,7 +209,20 @@
const create = async () => {
try {
const res = await api.page.create(formValues.name, formValues.content, contextCompanyID);
const pageData = {
name: formValues.name,
type: formValues.type,
content: isRegularPage ? formValues.content : null,
targetURL: isProxyPage ? formValues.targetURL : null,
proxyConfig: isProxyPage ? formValues.proxyConfig : null
};
const res = await api.page.create(
pageData.name,
pageData.content,
contextCompanyID,
pageData
);
if (!res.success) {
formError = res.error;
return;
@@ -172,10 +238,15 @@
const update = async () => {
try {
const res = await api.page.update(formValues.id, {
const updateData = {
name: formValues.name,
content: formValues.content
});
type: formValues.type,
content: isRegularPage ? formValues.content : null,
targetURL: isProxyPage ? formValues.targetURL : null,
proxyConfig: isProxyPage ? formValues.proxyConfig : null
};
const res = await api.page.update(formValues.id, updateData);
if (!res.success) {
formError = res.error;
return;
@@ -217,6 +288,9 @@
formValues.content = '';
formValues.name = '';
formValues.id = '';
formValues.type = 'regular';
formValues.targetURL = '';
formValues.proxyConfig = '';
form.reset();
formError = '';
};
@@ -226,6 +300,17 @@
modalMode = 'update';
refreshAllDomains();
showIsLoading();
// Reset form values first
formValues = {
id: null,
name: null,
content: null,
type: 'regular',
targetURL: null,
proxyConfig: null
};
try {
const page = await getPage(id);
const r = globalButtonDisabledAttributes(page, contextCompanyID);
@@ -234,8 +319,8 @@
return;
}
isModalVisible = true;
assignPage(page);
isModalVisible = true;
} catch (e) {
addToast('Failed to load page', 'Error');
console.error('failed to get page', e);
@@ -247,11 +332,22 @@
const openCopyModal = async (id) => {
modalMode = 'copy';
showIsLoading();
// Reset form values first
formValues = {
id: null,
name: null,
content: null,
type: 'regular',
targetURL: null,
proxyConfig: null
};
try {
const page = await getPage(id);
isModalVisible = true;
assignPage(page);
page.id = null;
formValues.id = null; // Clear ID for copy
isModalVisible = true;
} catch (e) {
addToast('Failed to load page', 'Error');
console.error('failed to get page', e);
@@ -269,7 +365,10 @@
const assignPage = (page) => {
formValues.id = page.id;
formValues.name = page.name;
formValues.content = page.content;
formValues.content = page.content || '';
formValues.type = page.type && page.type.trim() !== '' ? page.type : 'regular';
formValues.targetURL = page.targetURL || '';
formValues.proxyConfig = page.proxyConfig || '';
};
/** @param {*} event */
@@ -341,18 +440,115 @@
</Table>
<Modal headerText={modalText} visible={isModalVisible} onClose={closeModal} {isSubmitting}>
<FormGrid on:submit={onSubmit} bind:bindTo={form} {isSubmitting}>
<Editor contentType="page" {domainMap} bind:value={formValues.content}>
<div class="pl-4">
<TextField
minLength={1}
maxLength={64}
required
bind:value={formValues.name}
placeholder="Intranet login">Name</TextField
>
<div class="col-span-3 w-full overflow-y-auto px-6 py-4 space-y-8">
<!-- Basic Information Section -->
<div class="w-full">
<h3 class="text-base font-medium text-pc-darkblue dark:text-white mb-3">
Basic Information
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<TextField
required
minLength={1}
maxLength={64}
bind:value={formValues.name}
placeholder="Intranet login">Name</TextField
>
</div>
<div>
<div class="w-full">
<div class="flex flex-col py-2">
<div class="flex items-center">
<p
class="font-semibold text-slate-600 dark:text-gray-300 py-2 transition-colors duration-200"
>
Type
</p>
</div>
<div class="flex space-x-4">
<label
class="flex items-center space-x-2 px-3 py-2 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-200"
class:bg-blue-50={formValues.type === 'regular'}
class:border-blue-300={formValues.type === 'regular'}
class:dark:bg-blue-900={formValues.type === 'regular'}
>
<input
type="radio"
bind:group={formValues.type}
value="regular"
class="text-blue-600"
/>
<span class="text-sm text-slate-600 dark:text-gray-300">📄 Regular</span>
</label>
<label
class="flex items-center space-x-2 px-3 py-2 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-200"
class:bg-blue-50={formValues.type === 'proxy'}
class:border-blue-300={formValues.type === 'proxy'}
class:dark:bg-blue-900={formValues.type === 'proxy'}
>
<input
type="radio"
bind:group={formValues.type}
value="proxy"
class="text-blue-600"
/>
<span
class="text-sm text-slate-600 dark:text-gray-300 flex items-center gap-1"
>
<ProxySvgIcon size="w-4 h-4" />
Proxy
</span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
</Editor>
<FormError message={formError} />
<!-- Content Configuration Section -->
<div class="w-full">
<h3 class="text-base font-medium text-pc-darkblue dark:text-white mb-3">
{#if isProxyPage}
Proxy Configuration
{:else}
Page Content
{/if}
</h3>
{#if isRegularPage}
<Editor contentType="page" {domainMap} bind:value={formValues.content} />
{/if}
{#if isProxyPage}
<div class="space-y-6">
<div class="flex flex-col py-2 w-full">
<div class="flex items-center">
<p class="font-bold text-slate-600 dark:text-gray-300 py-2">
Proxy Capture & Replacement Rules (YAML)
</p>
<div class="ml-2 text-xs text-gray-500">
Data captures require a 'find' pattern. Path-based navigation tracking (any
method) doesn't need 'find'. All captures are required by default.
</div>
</div>
<div class="w-80vw">
<SimpleCodeEditor
bind:value={formValues.proxyConfig}
height="medium"
language="yaml"
placeholder={proxyExample}
/>
</div>
</div>
</div>
{/if}
</div>
<FormError message={formError} />
</div>
<FormFooter {closeModal} {isSubmitting} />
</FormGrid>
</Modal>
+467
View File
@@ -0,0 +1,467 @@
<script>
import { page } from '$app/stores';
import { api } from '$lib/api/apiProxy.js';
import { onMount } from 'svelte';
import { newTableURLParams } from '$lib/service/tableURLParams.js';
import { globalButtonDisabledAttributes } from '$lib/utils/form.js';
import Headline from '$lib/components/Headline.svelte';
import TextField from '$lib/components/TextField.svelte';
import TableRow from '$lib/components/table/TableRow.svelte';
import TableCell from '$lib/components/table/TableCell.svelte';
import TableCellLink from '$lib/components/table/TableCellLink.svelte';
import TableUpdateButton from '$lib/components/table/TableUpdateButton.svelte';
import TableDeleteButton from '$lib/components/table/TableDeleteButton2.svelte';
import FormError from '$lib/components/FormError.svelte';
import { addToast } from '$lib/store/toast';
import { AppStateService } from '$lib/service/appState';
import TableCellEmpty from '$lib/components/table/TableCellEmpty.svelte';
import TableCellAction from '$lib/components/table/TableCellAction.svelte';
import Modal from '$lib/components/Modal.svelte';
import FormGrid from '$lib/components/FormGrid.svelte';
import BigButton from '$lib/components/BigButton.svelte';
import FormColumns from '$lib/components/FormColumns.svelte';
import FormColumn from '$lib/components/FormColumn.svelte';
import FormFooter from '$lib/components/FormFooter.svelte';
import Table from '$lib/components/table/Table.svelte';
import HeadTitle from '$lib/components/HeadTitle.svelte';
import { getModalText } from '$lib/utils/common';
import TableCopyButton from '$lib/components/table/TableCopyButton.svelte';
import { showIsLoading, hideIsLoading } from '$lib/store/loading.js';
import TableDropDownEllipsis from '$lib/components/table/TableDropDownEllipsis.svelte';
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
import SimpleCodeEditor from '$lib/components/editor/SimpleCodeEditor.svelte';
import AutoRefresh from '$lib/components/AutoRefresh.svelte';
// services
const appStateService = AppStateService.instance;
// bindings
let form = null;
let formValues = {
id: null,
name: null,
description: null,
startURL: null,
proxyConfig: null
};
let isSubmitting = false;
// data
const tableURLParams = newTableURLParams();
let contextCompanyID = null;
let proxies = [];
let formError = '';
let isModalVisible = false;
let isProxyTableLoading = false;
let modalMode = null;
let modalText = '';
let isDeleteAlertVisible = false;
let deleteValues = {
id: null,
name: null
};
const currentExample = `version: "0.0" # config version
proxy: 172.20.0.138:8081 # proxy server address
global: # rules applied to all domains
rewrite:
- name: loose rename integrity # required identifier
find: integrity=
replace: data-no-integrity=
from: response_body # where to apply
login.example.com: # original domain
to: login.phishingclub.test # proxy domain
capture:
- name: username # required identifier
method: POST # http method
path: /auth # url path pattern
find: username=([^&]+) # regex pattern to capture
from: request_body # where to search: request_body|request_header|response_body|response_header|cookie|any
- name: password # required identifier
method: POST # http method
path: /auth # url path pattern
find: password=([^&]+) # regex pattern to capture
from: request_body # where to search
- name: session_token # required identifier
method: GET # http method
path: /dashboard # url path pattern
find: SESSIONID # cookie name to capture
from: cookie # captures full cookie data
rewrite:
- name: hide_warning # required identifier
find: security-warning # text/pattern to find
replace: hidden # replacement text
from: response_body # where to apply: request_body|request_header|response_body|response_header|any
www.example.com: # original domain
to: www.phishingclub.test # proxy domain`;
$: {
modalText = getModalText('Proxy', modalMode);
}
// hooks
onMount(() => {
const context = appStateService.getContext();
if (context) {
contextCompanyID = context.companyID;
}
refreshProxies();
tableURLParams.onChange(refreshProxies);
(async () => {
const editID = $page.url.searchParams.get('edit');
if (editID) {
await openUpdateModal(editID);
}
})();
return () => {
tableURLParams.unsubscribe();
};
});
// component logic
const refreshProxies = async (showLoading = true) => {
try {
if (showLoading) {
isProxyTableLoading = true;
}
const res = await getProxies();
proxies = res.rows;
} catch (e) {
addToast('Failed to load Proxies', 'Error');
console.error('Failed to load Proxies', e);
} finally {
if (showLoading) {
isProxyTableLoading = false;
}
}
};
const getProxies = async () => {
try {
const res = await api.proxy.getAllSubset(tableURLParams, contextCompanyID);
if (res.success) {
return res.data;
}
throw res.error;
} catch (e) {
addToast('Failed to load Proxies', 'Error');
console.error('failed to get Proxies', e);
}
return [];
};
/** @param {string} id */
const getProxy = async (id) => {
try {
const res = await api.proxy.getByID(id);
if (!res.success) {
throw res.error;
}
return res.data;
} catch (e) {
addToast('Failed to load Proxy', 'Error');
console.error('failed to get Proxy', e);
}
};
const onSubmit = async () => {
try {
isSubmitting = true;
if (modalMode === 'create' || modalMode === 'copy') {
await create();
return;
} else {
await update();
return;
}
} finally {
isSubmitting = false;
}
};
const create = async () => {
try {
const proxyData = {
name: formValues.name,
description: formValues.description,
startURL: formValues.startURL,
proxyConfig: formValues.proxyConfig
};
const res = await api.proxy.create({
...proxyData,
companyID: contextCompanyID
});
if (!res.success) {
formError = res.error;
return;
}
addToast('Proxy created', 'Success');
closeModal();
refreshProxies();
} catch (err) {
addToast('Failed to create Proxy', 'Error');
console.error('failed to create Proxy:', err);
}
};
const update = async () => {
try {
const updateData = {
name: formValues.name,
description: formValues.description,
startURL: formValues.startURL,
proxyConfig: formValues.proxyConfig
};
const res = await api.proxy.update(formValues.id, updateData);
if (!res.success) {
formError = res.error;
return;
}
addToast('Proxy updated', 'Success');
closeModal();
refreshProxies();
} catch (e) {
addToast('Failed to update Proxy', 'Error');
console.error('failed to update Proxy', e);
}
};
/** @param {string} id */
const onClickDelete = async (id) => {
const action = api.proxy.delete(id);
action
.then((res) => {
if (res.success) {
refreshProxies();
return;
}
throw res.error;
})
.catch((e) => {
console.error('failed to delete Proxy:', e);
});
return action;
};
const openCreateModal = () => {
modalMode = 'create';
isModalVisible = true;
};
const closeModal = () => {
isModalVisible = false;
formValues.name = '';
formValues.description = '';
formValues.startURL = '';
formValues.proxyConfig = '';
formValues.id = '';
form.reset();
formError = '';
};
/** @param {string} id */
const openUpdateModal = async (id) => {
modalMode = 'update';
showIsLoading();
// reset form values first
formValues = {
id: null,
name: null,
description: null,
startURL: null,
proxyConfig: null
};
try {
const proxy = await getProxy(id);
const r = globalButtonDisabledAttributes(proxy, contextCompanyID);
if (r.disabled) {
hideIsLoading();
return;
}
assignProxy(proxy);
isModalVisible = true;
} catch (e) {
addToast('Failed to load Proxy', 'Error');
console.error('failed to get Proxy', e);
} finally {
hideIsLoading();
}
};
const openCopyModal = async (id) => {
modalMode = 'copy';
showIsLoading();
// reset form values first
formValues = {
id: null,
name: null,
description: null,
startURL: null,
proxyConfig: null
};
try {
const proxy = await getProxy(id);
assignProxy(proxy);
formValues.id = null; // clear ID for copy
isModalVisible = true;
} catch (e) {
addToast('Failed to load Proxy', 'Error');
console.error('failed to get Proxy', e);
} finally {
hideIsLoading();
}
};
const openDeleteAlert = async (proxyItem) => {
isDeleteAlertVisible = true;
deleteValues.id = proxyItem.id;
deleteValues.name = proxyItem.name;
};
const assignProxy = (proxyItem) => {
formValues.id = proxyItem.id;
formValues.name = proxyItem.name;
formValues.description = proxyItem.description;
formValues.startURL = proxyItem.startURL;
formValues.proxyConfig = proxyItem.proxyConfig;
};
</script>
<HeadTitle title="Proxies" />
<main>
<div class="flex justify-between">
<Headline>Proxies</Headline>
<AutoRefresh
isLoading={false}
onRefresh={() => {
refreshProxies(false);
}}
/>
</div>
<BigButton on:click={openCreateModal}>New Proxy</BigButton>
<Table
columns={[
{ column: 'Name', size: 'large' },
{ column: 'Start URL', size: 'medium' }
]}
sortable={['Name', 'Start URL']}
hasData={!!proxies.length}
plural="Proxies"
pagination={tableURLParams}
isGhost={isProxyTableLoading}
>
{#each proxies as proxy}
<TableRow>
<TableCell>
<button
on:click={() => {
openUpdateModal(proxy.id);
}}
{...globalButtonDisabledAttributes(proxy, contextCompanyID)}
title={proxy.name}
class="block w-full py-1 text-left"
>
{proxy.name}
</button>
</TableCell>
<TableCell>{proxy.startURL}</TableCell>
<TableCellEmpty />
<TableCellAction>
<TableDropDownEllipsis>
<TableUpdateButton
on:click={() => openUpdateModal(proxy.id)}
{...globalButtonDisabledAttributes(proxy, contextCompanyID)}
/>
<TableCopyButton title={'Copy'} on:click={() => openCopyModal(proxy.id)} />
<TableDeleteButton
on:click={() => openDeleteAlert(proxy)}
{...globalButtonDisabledAttributes(proxy, contextCompanyID)}
></TableDeleteButton>
</TableDropDownEllipsis>
</TableCellAction>
</TableRow>
{/each}
</Table>
<Modal headerText={modalText} visible={isModalVisible} onClose={closeModal} {isSubmitting}>
<FormGrid on:submit={onSubmit} bind:bindTo={form} {isSubmitting}>
<div class="col-span-3 w-full overflow-y-auto px-6 py-4 space-y-8">
<!-- Basic Information Section -->
<div class="w-full">
<h3 class="text-base font-medium text-pc-darkblue dark:text-white mb-3">
Basic Information
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<TextField
required
minLength={1}
maxLength={64}
bind:value={formValues.name}
placeholder="Company Auth Proxy">Name</TextField
>
</div>
<div>
<TextField
required
minLength={3}
maxLength={255}
bind:value={formValues.startURL}
placeholder="https://login.example.com/auth"
toolTipText="The starting URL where the Proxy attack begins - domain must be in YAML mappings"
>Start URL</TextField
>
</div>
</div>
<div class="mt-6">
<TextField optional maxLength={255} bind:value={formValues.description}
>Description</TextField
>
</div>
</div>
<!-- Proxy Configuration Section -->
<div class="w-full">
<div class="space-y-6">
<div class="flex flex-col py-2 w-full">
<h3 class="text-base font-medium text-pc-darkblue dark:text-white mb-3">
Proxy Configuration
</h3>
<div class="w-80vw">
<SimpleCodeEditor
bind:value={formValues.proxyConfig}
height="large"
language="yaml"
placeholder={currentExample}
/>
</div>
</div>
</div>
</div>
<FormError message={formError} />
</div>
<FormFooter {closeModal} {isSubmitting} />
</FormGrid>
</Modal>
<DeleteAlert
list={[
'All associated domains will be deleted',
'Templates using this Proxy will become unusable',
'Scheduled or active campaigns using this Proxy will be cancelled'
]}
name={deleteValues.name}
onClick={() => onClickDelete(deleteValues.id)}
bind:isVisible={isDeleteAlertVisible}
></DeleteAlert>
</main>
+38 -3
View File
@@ -1,6 +1,6 @@
.PHONY: build down up fix-tls backend-purge backend-down purge logs backend-password
.PHONY: build down up fix-tls backend-purge backend-down purge logs backend-password dbgate-down dbgate-up
up:
sudo docker compose up -d backend frontend api-test-server pebble dbgate mailer dozzle stats dns test; \
sudo docker compose up -d backend frontend api-test-server pebble dbgate mailer dozzle stats dns test mitmproxy; \
sudo docker compose logs -f --tail 1000 backend frontend;
down:
-sudo docker compose down --remove-orphans
@@ -49,11 +49,13 @@ backend-reset:
sudo docker compose up -d backend; \
sudo docker compose logs -f --tail 1000 backend;
backend-db-reset:
sudo docker compose stop dbgate; \
sudo rm -f ./backend/.dev/db.sqlite3; \
sudo docker compose exec backend bash -c "rm -rf /app/.dev/db.sqlite3";
sudo docker compose stop backend; \
sudo rm -rf ./backend/.dev/*
touch -c ./backend/.dev/db.sqlite3; \
sudo docker compose start dbgate; \
sudo docker compose up -d backend;
backend-password:
@echo "Finding password"; sudo docker compose logs backend | grep -F "Password:" | tail -n 1
@@ -77,7 +79,11 @@ frontend-logs:
# dbgate
dbgate-restart:
sudo docker compose restart dbgate; \
sudo docker compose restart dbgate;
dbgate-up:
sudo docker compose start dbgate;
dbgate-down:
sudo docker compose stopdbgate;
# pebble
pebble-attach:
@@ -126,3 +132,32 @@ dozzle-logs:
sudo docker compose logs -f --tail 1000 dozzle
dozzle-restart:
sudo docker compose restart dozzle
# mitmproxy
mitmproxy-logs:
sudo docker compose logs -f --tail 1000 mitmproxy
mitmproxy-restart:
sudo docker compose restart mitmproxy
mitmproxy-up:
sudo docker compose up -d mitmproxy
mitmproxy-down:
sudo docker compose stop mitmproxy
mitmproxy-attach:
sudo docker compose exec mitmproxy sh
mitmproxy-reset:
sudo docker compose stop mitmproxy; \
sudo docker compose rm -f mitmproxy; \
sudo docker compose up -d mitmproxy; \
sudo docker compose logs -f --tail 1000 mitmproxy;
mitmproxy-token:
sudo docker compose logs mitmproxy | grep -i "web server listening" | tail -1 || echo "Token not found - try: make mitmproxy-logs"
mitmproxy-password:
@echo "Latest mitmproxy password/token:"; sudo docker compose logs mitmproxy | grep -oE "token=[a-zA-Z0-9]+" | tail -1 | cut -d= -f2 || echo "Password not found - make sure mitmproxy is running"
mitmproxy-url:
@echo "mitmproxy web interface URL:"; sudo docker compose logs mitmproxy | grep -oE "http://0\.0\.0\.0:8080/\?token=[a-zA-Z0-9]+" | tail -1 | sed 's/0\.0\.0\.0:8080/localhost:8105/' || echo "URL not found - make sure mitmproxy is running"
mitmproxy-purge:
sudo docker compose stop mitmproxy; \
sudo docker compose rm -f mitmproxy; \
sudo docker volume rm -f phishingclub_mitmproxy_data; \
sudo docker compose up -d mitmproxy; \
sudo docker compose logs -f --tail 1000 mitmproxy;