mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-06-09 16:03:58 +02:00
Proxy MITM
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(
|
||||
¤tNotableEventID,
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -23,4 +23,6 @@ const (
|
||||
OptionKeyRepeatOffenderMonths = "repeat_offender_months"
|
||||
|
||||
OptionKeyAdminSSOLogin = "sso_login"
|
||||
|
||||
OptionKeyProxyCookieName = "proxy_cookie_name"
|
||||
)
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 
 (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 
 (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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user