diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8b5bee..67ddc77 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,7 +47,7 @@ jobs: -v "$(pwd)":/app \ -w /app/backend \ -e CGO_ENABLED=1 \ - golang:1.24 \ + golang:1.25.1 \ go build -trimpath \ -ldflags='-X github.com/phishingclub/phishingclub/version.hash=ph${{ steps.get_version.outputs.HASH }} -X github.com/phishingclub/phishingclub/version.version=${{ steps.get_version.outputs.VERSION }}' \ -tags production -o ../build/phishingclub main.go diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 0bab508..00777ec 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -45,7 +45,7 @@ jobs: -v "$(pwd)":/app \ -w /app/backend \ -e CGO_ENABLED=1 \ - golang:1.24 \ + golang:1.25.1 \ go build -trimpath \ -ldflags='-X github.com/phishingclub/phishingclub/version.hash=ph${{ steps.get_version.outputs.HASH }} -X github.com/phishingclub/phishingclub/version.version=${{ steps.get_version.outputs.VERSION }}' \ -tags production -o ../build/phishingclub main.go diff --git a/RELEASE.md b/RELEASE.md index a3d839c..7d03f5c 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,5 +1,21 @@ # Changelog +## [1.3.0] - 2025-09-19 +- Added dark mode support and various UI improvements +- Added manual backup functionality +- Added reported functionality for phishing campaigns +- Added recipient manual send action +- Added validation on save +- Added link to release information on update modal and page +- Fixed copy campaign wrong text on create +- Fixed HTML to text template handling +- Fixed bad title on settings page +- Fixed dashboard scroll to top issue +- Improved send again texts +- Improved modal error position +- Moved recent campaigns to bottom of dashboard +- Bumped Go version and dependencies + ## [1.2.1] - 2025-09-15 - Add debug logging to SMTP - Fix excessive table URL params diff --git a/backend/Dockerfile b/backend/Dockerfile index c9ae40c..d5a0b3c 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,5 @@ # development docker file -FROM golang:1.24.5 +FROM golang:1.25.1 EXPOSE 8000 8001 diff --git a/backend/app/administration.go b/backend/app/administration.go index 81c039a..d82f556 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -36,6 +36,10 @@ const ( ROUTE_V1_UPDATE_AVAILABLE = "/api/v1/update/available" ROUTE_V1_UPDATE_AVAILABLE_CACHED = "/api/v1/update/available/cached" ROUTE_V1_UPDATE = "/api/v1/update" + // backup + ROUTE_V1_BACKUP_CREATE = "/api/v1/backup/create" + ROUTE_V1_BACKUP_LIST = "/api/v1/backup/list" + ROUTE_V1_BACKUP_DOWNLOAD = "/api/v1/backup/download/:filename" // user ROUTE_V1_USER = "/api/v1/user" ROUTE_V1_USER_ID = "/api/v1/user/:id" @@ -132,10 +136,12 @@ const ( ROUTE_V1_CAMPAIGN_STATS = "/api/v1/campaign/statistics" ROUTE_V1_CAMPAIGN_STATS_ID = "/api/v1/campaign/:id/stats" ROUTE_V1_CAMPAIGN_STATS_ALL = "/api/v1/campaign/stats/all" + ROUTE_V1_CAMPAIGN_UPLOAD_REPORTED = "/api/v1/campaign/:id/upload/reported" // campaign-recipient - ROUTE_V1_CAMPAIGN_RECIPIENT_EMAIL = "/api/v1/campaign/recipient/:id/email" - ROUTE_V1_CAMPAIGN_RECIPIENT_URL = "/api/v1/campaign/recipient/:id/url" - ROUTE_V1_CAMPAIGN_RECIPIENT_SET_SENT = "/api/v1/campaign/recipient/:id/sent" + ROUTE_V1_CAMPAIGN_RECIPIENT_EMAIL = "/api/v1/campaign/recipient/:id/email" + ROUTE_V1_CAMPAIGN_RECIPIENT_URL = "/api/v1/campaign/recipient/:id/url" + ROUTE_V1_CAMPAIGN_RECIPIENT_SET_SENT = "/api/v1/campaign/recipient/:id/sent" + ROUTE_V1_CAMPAIGN_RECIPIENT_SEND_EMAIL = "/api/v1/campaign/recipient/:id/send" // asset ROUTE_V1_ASSET = "/api/v1/asset" ROUTE_V1_ASSET_ID = "/api/v1/asset/:id" @@ -364,6 +370,7 @@ func setupRoutes( POST(ROUTE_V1_CAMPAIGN_CLOSE, middleware.SessionHandler, controllers.Campaign.CloseCampaignByID). GET(ROUTE_V1_CAMPAIGN_EXPORT_EVENTS, middleware.SessionHandler, controllers.Campaign.ExportEventsAsCSV). GET(ROUTE_V1_CAMPAIGN_EXPORT_SUBMISSIONS, middleware.SessionHandler, controllers.Campaign.ExportSubmissionsAsCSV). + POST(ROUTE_V1_CAMPAIGN_UPLOAD_REPORTED, middleware.SessionHandler, controllers.Campaign.UploadReportedCSV). POST(ROUTE_V1_CAMPAIGN_ANONYMIZE, middleware.SessionHandler, controllers.Campaign.AnonymizeByID). DELETE(ROUTE_V1_CAMPAIGN_ID, middleware.SessionHandler, controllers.Campaign.DeleteByID). // campaign-recipient @@ -371,6 +378,7 @@ func setupRoutes( GET(ROUTE_V1_CAMPAIGN_RECIPIENT_EMAIL, middleware.SessionHandler, controllers.Campaign.GetCampaignEmail). GET(ROUTE_V1_CAMPAIGN_RECIPIENT_URL, middleware.SessionHandler, controllers.Campaign.GetCampaignURL). POST(ROUTE_V1_CAMPAIGN_RECIPIENT_SET_SENT, middleware.SessionHandler, controllers.Campaign.SetSentAtByCampaignRecipientID). + POST(ROUTE_V1_CAMPAIGN_RECIPIENT_SEND_EMAIL, middleware.SessionHandler, controllers.Campaign.SendEmailByCampaignRecipientID). // asset GET(ROUTE_V1_ASSET_DOMAIN_VIEW, middleware.SessionHandler, controllers.Asset.GetContentByID). GET(ROUTE_V1_ASSET_ID, middleware.SessionHandler, controllers.Asset.GetByID). @@ -415,6 +423,10 @@ func setupRoutes( // update GET(ROUTE_V1_UPDATE, middleware.SessionHandler, controllers.Update.GetUpdateDetails). POST(ROUTE_V1_UPDATE, middleware.SessionHandler, controllers.Update.RunUpdate). + // backup + POST(ROUTE_V1_BACKUP_CREATE, middleware.SessionHandler, controllers.Backup.CreateBackup). + GET(ROUTE_V1_BACKUP_LIST, middleware.SessionHandler, controllers.Backup.ListBackups). + GET(ROUTE_V1_BACKUP_DOWNLOAD, middleware.SessionHandler, controllers.Backup.DownloadBackup). // import POST(ROUTE_V1_IMPORT, middleware.SessionHandler, controllers.Import.Import) diff --git a/backend/app/controllers.go b/backend/app/controllers.go index 33d59b3..943206e 100644 --- a/backend/app/controllers.go +++ b/backend/app/controllers.go @@ -34,6 +34,7 @@ type Controllers struct { SSO *controller.SSO Update *controller.Update Import *controller.Import + Backup *controller.Backup } // NewControllers creates a collection of controllers @@ -168,6 +169,10 @@ func NewControllers( Common: common, ImportService: services.Import, } + backup := &controller.Backup{ + Common: common, + BackupService: services.Backup, + } return &Controllers{ Asset: asset, @@ -196,5 +201,6 @@ func NewControllers( SSO: sso, Update: update, Import: importController, + Backup: backup, } } diff --git a/backend/app/server.go b/backend/app/server.go index aca0aed..df1978c 100644 --- a/backend/app/server.go +++ b/backend/app/server.go @@ -12,7 +12,7 @@ import ( "os" "path/filepath" "strings" - "text/template" + textTmpl "text/template" "time" "github.com/go-errors/errors" @@ -316,7 +316,7 @@ func (s *Server) Handler(c *gin.Context) { return } // TODO extract this into another method, maybe file - t, err := template. + t, err := textTmpl. New("staticContent"). Funcs(service.TemplateFuncs()). Parse(string(domain.PageNotFoundContent)) @@ -363,7 +363,7 @@ func (s *Server) Handler(c *gin.Context) { c.Abort() return } - t, err := template. + t, err := textTmpl. New("staticContent"). Funcs(service.TemplateFuncs()). Parse(domain.PageContent) @@ -421,7 +421,7 @@ func (s *Server) handlerNotFound(c *gin.Context) { c.Status(http.StatusNotFound) return } - t := template.New("staticContent") + t := textTmpl.New("staticContent") t = t.Funcs(service.TemplateFuncs()) tmpl, err := t.Parse(string(domain.PageNotFoundContent)) if err != nil { @@ -919,7 +919,9 @@ func (s *Server) renderDenyPage( if err != nil { return fmt.Errorf("failed to get landing page: %s", err) } - tmpl, err := template.New("page").Parse(page.Content.MustGet().String()) + tmpl, err := textTmpl.New("page"). + Funcs(service.TemplateFuncs()). + Parse(page.Content.MustGet().String()) if err != nil { return fmt.Errorf("failed to parse page template: %s", err) } diff --git a/backend/app/services.go b/backend/app/services.go index fb23e4a..daab92b 100644 --- a/backend/app/services.go +++ b/backend/app/services.go @@ -34,6 +34,7 @@ type Services struct { SSO *service.SSO Update *service.Update Import *service.Import + Backup *service.Backup } // NewServices creates a collection of services @@ -49,6 +50,7 @@ func NewServices( certMagicConfig *certmagic.Config, certMagicCache *certmagic.Cache, licenseServerURL string, + filePath string, ) *Services { common := service.Common{ Logger: logger, @@ -138,6 +140,7 @@ func NewServices( CampaignRepository: repositories.Campaign, PageRepository: repositories.Page, CampaignTemplateService: campaignTemplate, + TemplateService: templateService, } domain := &service.Domain{ Common: common, @@ -149,6 +152,7 @@ func NewServices( CampaignTemplateService: campaignTemplate, AssetService: asset, FileService: file, + TemplateService: templateService, } email := &service.Email{ Common: common, @@ -210,6 +214,12 @@ func NewServices( SessionService: sessionService, // MSALClient: msalClient, this dependency is set AFTER this function } + backupService := &service.Backup{ + Common: common, + OptionService: optionService, + DB: db, + FilePath: filePath, + } updateService := &service.Update{ Common: common, OptionService: optionService, @@ -250,5 +260,6 @@ func NewServices( SSO: ssoService, Update: updateService, Import: importService, + Backup: backupService, } } diff --git a/backend/cache/local.go b/backend/cache/local.go index a123c5c..4b5bf09 100644 --- a/backend/cache/local.go +++ b/backend/cache/local.go @@ -37,6 +37,7 @@ func IsUpdateAvailable() bool { // readonly var CampaignEventPriority = map[string]int{ // campaign recipient events + data.EVENT_CAMPAIGN_RECIPIENT_REPORTED: 90, data.EVENT_CAMPAIGN_RECIPIENT_CANCELLED: 80, data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA: 70, data.EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED: 60, diff --git a/backend/controller/backup.go b/backend/controller/backup.go new file mode 100644 index 0000000..2516d2b --- /dev/null +++ b/backend/controller/backup.go @@ -0,0 +1,74 @@ +package controller + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/phishingclub/phishingclub/service" +) + +type Backup struct { + Common + BackupService *service.Backup +} + +// CreateBackup starts a backup operation +func (b *Backup) CreateBackup(g *gin.Context) { + session, _, ok := b.handleSession(g) + if !ok { + return + } + + err := b.BackupService.CreateBackup(g, session) + if ok := b.handleErrors(g, err); !ok { + return + } + + b.Response.OK(g, gin.H{ + "message": "backup started", + }) +} + +// ListBackups returns a list of available backup files +func (b *Backup) ListBackups(g *gin.Context) { + session, _, ok := b.handleSession(g) + if !ok { + return + } + + backups, err := b.BackupService.ListBackups(g, session) + if ok := b.handleErrors(g, err); !ok { + return + } + + b.Response.OK(g, backups) +} + +// DownloadBackup serves a backup file for download +func (b *Backup) DownloadBackup(g *gin.Context) { + session, _, ok := b.handleSession(g) + if !ok { + return + } + + filename := g.Param("filename") + if filename == "" { + g.JSON(http.StatusBadRequest, gin.H{"error": "filename is required"}) + return + } + + backupFile, err := b.BackupService.GetBackupFile(g, session, filename) + if ok := b.handleErrors(g, err); !ok { + return + } + defer backupFile.Close() + + // set headers for file download + g.Header("Content-Description", "File Transfer") + g.Header("Content-Transfer-Encoding", "binary") + g.Header("Content-Disposition", "attachment; filename="+filename) + g.Header("Content-Type", "application/octet-stream") + + // serve the file content directly from the secure file handle + g.DataFromReader(http.StatusOK, -1, "application/octet-stream", backupFile, nil) +} diff --git a/backend/controller/campaign.go b/backend/controller/campaign.go index 1e66399..3e9fb8f 100644 --- a/backend/controller/campaign.go +++ b/backend/controller/campaign.go @@ -3,6 +3,8 @@ package controller import ( "bytes" "encoding/csv" + "io" + "strings" "time" "github.com/go-errors/errors" @@ -852,6 +854,27 @@ func (c *Campaign) SetSentAtByCampaignRecipientID(g *gin.Context) { c.Response.OK(g, gin.H{}) } +// SendEmailByCampaignRecipientID sends an email to a specific campaign recipient +func (c *Campaign) SendEmailByCampaignRecipientID(g *gin.Context) { + // handle session + session, _, ok := c.handleSession(g) + if !ok { + return + } + // parse request + id, ok := c.handleParseIDParam(g) + if !ok { + return + } + // send message (email or API depending on campaign template configuration) + err := c.CampaignService.SendEmailByCampaignRecipientID(g.Request.Context(), session, id) + // handle responses + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, gin.H{}) +} + // DeleteByID deletes a campaign by its id func (c *Campaign) DeleteByID(g *gin.Context) { // handle session @@ -935,3 +958,70 @@ func (c *Campaign) GetAllCampaignStats(g *gin.Context) { } c.Response.OK(g, stats) } + +// UploadReportedCSV uploads a CSV file with reported recipients +func (c *Campaign) UploadReportedCSV(g *gin.Context) { + // handle session + session, _, ok := c.handleSession(g) + if !ok { + return + } + // parse campaign id + id, ok := c.handleParseIDParam(g) + if !ok { + return + } + + // get the uploaded file + file, header, err := g.Request.FormFile("file") + if err != nil { + c.Response.ValidationFailed(g, "file", err) + return + } + defer file.Close() + + // validate file extension + if !strings.HasSuffix(strings.ToLower(header.Filename), ".csv") { + c.Response.ValidationFailed(g, "file", errors.New("file must be a CSV")) + return + } + + // read file content + content, err := io.ReadAll(file) + if err != nil { + c.Response.ValidationFailed(g, "file", err) + return + } + + // parse CSV + reader := csv.NewReader(strings.NewReader(string(content))) + records, err := reader.ReadAll() + if err != nil { + c.Logger.Errorw("failed to parse CSV file", "error", err) + c.Response.ValidationFailed(g, "file", errors.New("failed to parse CSV file: "+err.Error())) + return + } + + if len(records) < 2 { + c.Logger.Debugw("CSV file has insufficient rows", "rows", len(records)) + c.Response.ValidationFailed(g, "file", errors.New("CSV file must have header and at least one data row")) + return + } + + c.Logger.Debugw("processing CSV", "rows", len(records), "headers", records[0]) + + // process CSV + processed, skipped, err := c.CampaignService.ProcessReportedCSV(g.Request.Context(), session, id, records) + if err != nil { + c.Logger.Errorw("failed to process reported CSV", "error", err) + if ok := c.handleErrors(g, err); !ok { + return + } + } + + c.Response.OK(g, gin.H{ + "processed": processed, + "skipped": skipped, + "message": "CSV processed successfully", + }) +} diff --git a/backend/data/events.go b/backend/data/events.go index b01ca8c..e04950a 100644 --- a/backend/data/events.go +++ b/backend/data/events.go @@ -14,6 +14,7 @@ const ( EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED = "campaign_recipient_page_visited" EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED = "campaign_recipient_after_page_visited" EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA = "campaign_recipient_submitted_data" + EVENT_CAMPAIGN_RECIPIENT_REPORTED = "campaign_recipient_reported" EVENT_CAMPAIGN_RECIPIENT_CANCELLED = "campaign_recipient_cancelled" ) @@ -32,5 +33,6 @@ var Events = []string{ EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED, EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED, EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA, + EVENT_CAMPAIGN_RECIPIENT_REPORTED, EVENT_CAMPAIGN_RECIPIENT_CANCELLED, } diff --git a/backend/database/campaignStats.go b/backend/database/campaignStats.go index 965dc1b..2bc4747 100644 --- a/backend/database/campaignStats.go +++ b/backend/database/campaignStats.go @@ -35,11 +35,13 @@ type CampaignStats struct { TrackingPixelLoaded int `gorm:"not null;default:0" json:"trackingPixelLoaded"` // Email opens WebsiteVisits int `gorm:"not null;default:0" json:"websiteVisits"` // Link clicks DataSubmissions int `gorm:"not null;default:0" json:"dataSubmissions"` // Form submissions + Reported int `gorm:"not null;default:0" json:"reported"` // Reported phishing // Success rates (as percentages for quick display) OpenRate float64 `gorm:"not null;default:0" json:"openRate"` ClickRate float64 `gorm:"not null;default:0" json:"clickRate"` SubmissionRate float64 `gorm:"not null;default:0" json:"submissionRate"` + ReportRate float64 `gorm:"not null;default:0" json:"reportRate"` // Campaign metadata TemplateName string `gorm:"" json:"templateName"` diff --git a/backend/go.mod b/backend/go.mod index 8041362..a17a8ae 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module github.com/phishingclub/phishingclub -go 1.23.6 +go 1.25.1 require ( github.com/AzureAD/microsoft-authentication-library-for-go v1.3.2 diff --git a/backend/install/installer.go b/backend/install/installer.go index 1932b61..54d320f 100644 --- a/backend/install/installer.go +++ b/backend/install/installer.go @@ -5,11 +5,11 @@ import ( "bytes" "embed" "fmt" - "html/template" "os" "os/exec" "path/filepath" "strings" + "text/template" "time" ) diff --git a/backend/main.go b/backend/main.go index 3d4cae7..ef69f2a 100644 --- a/backend/main.go +++ b/backend/main.go @@ -225,6 +225,7 @@ func main() { certMagicConfig, certMagicCache, licenseServer, + *flagFilePath, ) // get entra-id options and setup msal client ssoOpt, err := services.SSO.GetSSOOptionWithoutAuth(context.Background()) diff --git a/backend/model/campaignResultView.go b/backend/model/campaignResultView.go index 938e338..3a4d291 100644 --- a/backend/model/campaignResultView.go +++ b/backend/model/campaignResultView.go @@ -6,4 +6,5 @@ type CampaignResultView struct { TrackingPixelLoaded int64 `json:"trackingPixelLoaded"` WebsiteLoaded int64 `json:"clickedLink"` SubmittedData int64 `json:"submittedData"` + Reported int64 `json:"reported"` } diff --git a/backend/model/recipientCampaignStatsView.go b/backend/model/recipientCampaignStatsView.go index d40d975..048edee 100644 --- a/backend/model/recipientCampaignStatsView.go +++ b/backend/model/recipientCampaignStatsView.go @@ -5,6 +5,7 @@ type RecipientCampaignStatsView struct { CampaignsTrackingPixelLoaded int64 `json:"campaignsTrackingPixelLoaded"` CampaignsPhishingPageLoaded int64 `json:"campaignsPhishingPageLoaded"` CampaignsDataSubmitted int64 `json:"campaignsDataSubmitted"` + CampaignsReported int64 `json:"campaignsReported"` RepeatLinkClicks int64 `json:"repeatLinkClicks"` RepeatSubmissions int64 `json:"repeatSubmissions"` } diff --git a/backend/repository/campaign.go b/backend/repository/campaign.go index 773c0dc..5b1c567 100644 --- a/backend/repository/campaign.go +++ b/backend/repository/campaign.go @@ -794,6 +794,32 @@ func (r *Campaign) GetResultStats( return nil, res.Error } + // Get unique reported + res = r.DB.Raw(` + SELECT COUNT(*) FROM ( + SELECT DISTINCT recipient_id + FROM campaign_events + WHERE campaign_id = ? + AND event_id = ? + AND recipient_id IS NOT NULL + UNION + SELECT DISTINCT anonymized_id + FROM campaign_events + WHERE campaign_id = ? + AND event_id = ? + AND anonymized_id IS NOT NULL + ) as unique_ids +`, + campaignID, + cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_REPORTED], + campaignID, + cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_REPORTED], + ).Scan(&stats.Reported) + + if res.Error != nil { + return nil, res.Error + } + return stats, nil } diff --git a/backend/repository/recipient.go b/backend/repository/recipient.go index f89a875..542904a 100644 --- a/backend/repository/recipient.go +++ b/backend/repository/recipient.go @@ -419,6 +419,18 @@ func (r *Recipient) GetStatsByID( Distinct("campaign_events.campaign_id"). Count(&stats.CampaignsDataSubmitted) + // get unique reported campaigns + r.DB.Model(&database.CampaignEvent{}). + Joins("JOIN campaigns ON campaigns.id = campaign_events.campaign_id"). + Where( + "campaign_events.recipient_id = ? AND campaign_events.event_id = ? AND campaigns.is_test = ?", + id, + cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_REPORTED], + false, + ). + Distinct("campaign_events.campaign_id"). + Count(&stats.CampaignsReported) + // Get repeat link clicks in last selected threshold months var linkClickCount int64 r.DB.Model(&database.CampaignEvent{}). diff --git a/backend/service/apiSender.go b/backend/service/apiSender.go index 702affb..90ed168 100644 --- a/backend/service/apiSender.go +++ b/backend/service/apiSender.go @@ -5,10 +5,10 @@ import ( "context" "encoding/json" "fmt" - "html/template" "io" "net/http" "strings" + "text/template" "time" "github.com/go-errors/errors" @@ -651,6 +651,7 @@ func (a *APISender) buildHeader( if err != nil { return nil, fmt.Errorf("failed to parse header value: %s", err) } + valueTemplate = valueTemplate.Funcs(TemplateFuncs()) var value bytes.Buffer if err := valueTemplate.Execute(&value, nil); err != nil { return nil, fmt.Errorf("failed to execute value template: %s", err) @@ -767,9 +768,7 @@ func (a *APISender) buildRequest( } // Remove the newline that Encode adds and the surrounding quotes jsonStr := strings.TrimSpace(buf.String()) - - // Mark as safe HTML so template won't escape it - (*t)["Content"] = template.HTML(jsonStr[1 : len(jsonStr)-1]) + (*t)["Content"] = jsonStr[1 : len(jsonStr)-1] contentTemplate := template.New("content") contentTemplate = contentTemplate.Funcs(TemplateFuncs()) contentTemplate, err = contentTemplate.Parse(apiSender.RequestBody.MustGet().String()) diff --git a/backend/service/backup.go b/backend/service/backup.go new file mode 100644 index 0000000..fbbddd7 --- /dev/null +++ b/backend/service/backup.go @@ -0,0 +1,582 @@ +package service + +import ( + "archive/tar" + "compress/gzip" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/go-errors/errors" + "gorm.io/gorm" + + "github.com/phishingclub/phishingclub/data" + "github.com/phishingclub/phishingclub/errs" + "github.com/phishingclub/phishingclub/model" + "github.com/phishingclub/phishingclub/validate" +) + +// BackupFile represents a backup file available for download +type BackupFile struct { + Name string `json:"name"` + Size int64 `json:"size"` + CreatedAt time.Time `json:"createdAt"` + RelativePath string `json:"relativePath"` +} + +type Backup struct { + Common + OptionService *Option + DB *gorm.DB + FilePath string // base file path for application data +} + +// BackupStatus represents the status of a backup operation +type BackupStatus struct { + IsRunning bool `json:"isRunning"` + IsComplete bool `json:"isComplete"` + HasError bool `json:"hasError"` + ErrorMessage string `json:"errorMessage"` + StartTime time.Time `json:"startTime"` + EndTime time.Time `json:"endTime"` + BackupPath string `json:"backupPath"` + Progress string `json:"progress"` +} + +// BackupResult represents the result of a backup operation +type BackupResult struct { + BackupPath string `json:"backupPath"` + DatabaseSize int64 `json:"databaseSize"` + FilesSize int64 `json:"filesSize"` + TotalSize int64 `json:"totalSize"` + Duration time.Duration `json:"duration"` +} + +var ( + currentBackupStatus *BackupStatus +) + +// CreateBackup creates a backup of the database and files +func (b *Backup) CreateBackup( + ctx context.Context, + session *model.Session, +) error { + ae := NewAuditEvent("Backup.CreateBackup", session) + + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil { + b.LogAuthError(err) + return errs.Wrap(err) + } + if !isAuthorized { + b.AuditLogNotAuthorized(ae) + return errors.New("unauthorized") + } + + // check if backup is already running + if currentBackupStatus != nil && currentBackupStatus.IsRunning { + return errors.New("backup already in progress") + } + + // initialize backup status + currentBackupStatus = &BackupStatus{ + IsRunning: true, + IsComplete: false, + HasError: false, + StartTime: time.Now(), + Progress: "starting backup", + } + + // run backup synchronously to lock interface + err = b.performBackup(ctx) + currentBackupStatus.IsRunning = false + currentBackupStatus.EndTime = time.Now() + + if err != nil { + currentBackupStatus.HasError = true + currentBackupStatus.ErrorMessage = err.Error() + b.Logger.Errorw("backup failed", "error", err) + b.AuditLogAuthorized(ae) + return errs.Wrap(err) + } else { + currentBackupStatus.IsComplete = true + currentBackupStatus.Progress = "backup completed" + b.Logger.Infow("backup completed successfully", "path", currentBackupStatus.BackupPath) + + // automatically cleanup old backups to maintain maximum of 3 + currentBackupStatus.Progress = "cleaning up old backups" + cleanupErr := b.CleanupOldBackups(ctx, session, 3) + if cleanupErr != nil { + b.Logger.Warnw("failed to cleanup old backups", "error", cleanupErr) + // don't fail the backup operation if cleanup fails + } else { + b.Logger.Debugw("cleaned up old backups, keeping latest 3") + } + ae.Details["backupPath"] = currentBackupStatus.BackupPath + } + + if currentBackupStatus.HasError { + ae.Details["error"] = currentBackupStatus.ErrorMessage + b.AuditLogAuthorized(ae) + return errs.Wrap(errors.New(currentBackupStatus.ErrorMessage)) + } + + b.AuditLogAuthorized(ae) + return nil +} + +// performBackup performs the actual backup operation +func (b *Backup) performBackup(ctx context.Context) error { + timestamp := time.Now().Format("20060102-150405") + filesPath := strings.TrimSuffix(b.FilePath, "/") + backupDir := filepath.Join(filesPath, "backups", fmt.Sprintf("backup-%s", timestamp)) + + // create backup directory + if err := os.MkdirAll(backupDir, 0755); err != nil { + return errs.Wrap(err) + } + + currentBackupStatus.Progress = "backing up database" + b.Logger.Debugw("starting database backup") + + // backup database directly to backup root + if err := b.backupDatabase(ctx, backupDir); err != nil { + return errs.Wrap(err) + } + + currentBackupStatus.Progress = "backing up files" + b.Logger.Debugw("starting files backup") + + // backup files directly to backup root (preserving directory structure) + if err := b.backupFiles(backupDir); err != nil { + return errs.Wrap(err) + } + + currentBackupStatus.Progress = "compressing backup" + b.Logger.Debugw("compressing backup") + + // compress backup + backupArchive := backupDir + ".tar.gz" + if err := b.compressBackup(backupDir, backupArchive); err != nil { + return errs.Wrap(err) + } + + // remove uncompressed backup directory + if err := os.RemoveAll(backupDir); err != nil { + b.Logger.Warnw("failed to remove uncompressed backup directory", "error", err) + } + + currentBackupStatus.BackupPath = backupArchive + return nil +} + +// backupDatabase creates a backup of the sqlite database +func (b *Backup) backupDatabase(ctx context.Context, backupPath string) error { + // get the underlying sql.DB + sqlDB, err := b.DB.DB() + if err != nil { + return errs.Wrap(err) + } + + // execute wal checkpoint to ensure all data is written to main db file + _, err = sqlDB.ExecContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)") + if err != nil { + b.Logger.Warnw("failed to checkpoint wal", "error", err) + // continue anyway as this is not critical + } + + // extract database path from DSN + dbPath := b.extractDatabasePath() + + // copy main database file + if err := b.copyFile(dbPath, filepath.Join(backupPath, "db.sqlite3")); err != nil { + return errs.Wrap(err) + } + + // copy wal file if it exists + walPath := dbPath + "-wal" + if _, err := os.Stat(walPath); err == nil { + if err := b.copyFile(walPath, filepath.Join(backupPath, "db.sqlite3-wal")); err != nil { + b.Logger.Debugw("failed to copy wal file", "error", err) + } + } + + // copy shm file if it exists + shmPath := dbPath + "-shm" + if _, err := os.Stat(shmPath); err == nil { + if err := b.copyFile(shmPath, filepath.Join(backupPath, "db.sqlite3-shm")); err != nil { + b.Logger.Debugw("failed to copy shm file", "error", err) + } + } + + return nil +} + +// backupFiles creates a backup of application files +func (b *Backup) backupFiles(backupPath string) error { + // files are stored in the path specified by --files flag + // remove trailing slash if present for consistent path joining + filesPath := strings.TrimSuffix(b.FilePath, "/") + + filesToBackup := []string{"assets", "attachments", "certs"} + + for _, dir := range filesToBackup { + srcPath := filepath.Join(filesPath, dir) + dstPath := filepath.Join(backupPath, dir) + + // check if source directory exists + if _, err := os.Stat(srcPath); os.IsNotExist(err) { + b.Logger.Debugw("directory does not exist, skipping", "path", srcPath) + continue + } + + // copy directory + if err := b.copyDir(srcPath, dstPath); err != nil { + return errs.Wrap(err) + } + } + + return nil +} + +// extractDatabasePath extracts the database file path from the GORM DSN +func (b *Backup) extractDatabasePath() string { + // get the underlying sql.DB to access the data source name + sqlDB, err := b.DB.DB() + if err != nil { + b.Logger.Debugw("failed to get sql.DB, using default path", "error", err) + return "./db.sqlite3" + } + + // try to get database list to find the actual file path + rows, err := sqlDB.Query("PRAGMA database_list") + if err != nil { + b.Logger.Debugw("failed to query database list, using default path", "error", err) + return "./db.sqlite3" + } + defer rows.Close() + + for rows.Next() { + var seq int + var name, file string + err := rows.Scan(&seq, &name, &file) + if err != nil { + continue + } + // main database has seq=0 and name="main" + if seq == 0 && name == "main" && file != "" { + b.Logger.Debugw("found database path from PRAGMA database_list", "path", file) + return file + } + } + + // fallback to default + b.Logger.Debugw("could not determine database path from PRAGMA, using default") + return "./db.sqlite3" +} + +// compressBackup compresses the backup directory into a tar.gz file +func (b *Backup) compressBackup(srcDir, dstFile string) error { + file, err := os.Create(dstFile) + if err != nil { + return errs.Wrap(err) + } + defer file.Close() + + gzipWriter := gzip.NewWriter(file) + defer gzipWriter.Close() + + tarWriter := tar.NewWriter(gzipWriter) + defer tarWriter.Close() + + return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // get relative path + relPath, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + + // create tar header + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + header.Name = relPath + + // write header + if err := tarWriter.WriteHeader(header); err != nil { + return err + } + + // write file content if it's a regular file + if info.Mode().IsRegular() { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + _, err = io.Copy(tarWriter, file) + return err + } + + return nil + }) +} + +// copyFile copies a file from src to dst +func (b *Backup) copyFile(src, dst string) error { + sourceFile, err := os.Open(src) + if err != nil { + return errs.Wrap(err) + } + defer sourceFile.Close() + + // create destination directory if it doesn't exist + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return errs.Wrap(err) + } + + destFile, err := os.Create(dst) + if err != nil { + return errs.Wrap(err) + } + defer destFile.Close() + + _, err = io.Copy(destFile, sourceFile) + return errs.Wrap(err) +} + +// copyDir recursively copies a directory from src to dst +func (b *Backup) copyDir(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // get relative path + relPath, err := filepath.Rel(src, path) + if err != nil { + return err + } + + dstPath := filepath.Join(dst, relPath) + + if info.IsDir() { + return os.MkdirAll(dstPath, info.Mode()) + } + + return b.copyFile(path, dstPath) + }) +} + +// CleanupOldBackups removes old backup files to save disk space +func (b *Backup) CleanupOldBackups( + ctx context.Context, + session *model.Session, + keepCount int, +) error { + ae := NewAuditEvent("Backup.CleanupOldBackups", session) + ae.Details["keepCount"] = keepCount + + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil { + b.LogAuthError(err) + return errs.Wrap(err) + } + if !isAuthorized { + b.AuditLogNotAuthorized(ae) + return errs.ErrAuthorizationFailed + } + + filesPath := strings.TrimSuffix(b.FilePath, "/") + backupDir := filepath.Join(filesPath, "backups") + if _, err := os.Stat(backupDir); os.IsNotExist(err) { + return nil // no backups directory + } + + // get all backup files + files, err := os.ReadDir(backupDir) + if err != nil { + return errs.Wrap(err) + } + + // filter backup files and sort by modification time + var backupFiles []os.FileInfo + for _, file := range files { + if strings.HasPrefix(file.Name(), "backup-") && strings.HasSuffix(file.Name(), ".tar.gz") { + info, err := file.Info() + if err != nil { + continue + } + backupFiles = append(backupFiles, info) + } + } + + // if we have more backups than we want to keep, delete the oldest ones + if len(backupFiles) > keepCount { + // sort by modification time (oldest first) + for i := 0; i < len(backupFiles)-1; i++ { + for j := i + 1; j < len(backupFiles); j++ { + if backupFiles[i].ModTime().After(backupFiles[j].ModTime()) { + backupFiles[i], backupFiles[j] = backupFiles[j], backupFiles[i] + } + } + } + + // delete oldest files + filesToDelete := len(backupFiles) - keepCount + deletedFiles := []string{} + for i := 0; i < filesToDelete; i++ { + filePath := filepath.Join(backupDir, backupFiles[i].Name()) + if err := os.Remove(filePath); err != nil { + b.Logger.Warnw("failed to delete old backup", "file", filePath, "error", err) + } else { + b.Logger.Debugw("deleted old backup", "file", filePath) + deletedFiles = append(deletedFiles, backupFiles[i].Name()) + } + } + ae.Details["deletedFiles"] = deletedFiles + ae.Details["deletedCount"] = len(deletedFiles) + } + + ae.Details["totalBackups"] = len(backupFiles) + b.AuditLogAuthorized(ae) + return nil +} + +// ListBackups returns a list of available backup files +func (b *Backup) ListBackups( + ctx context.Context, + session *model.Session, +) ([]BackupFile, error) { + ae := NewAuditEvent("Backup.ListBackups", session) + + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil { + b.LogAuthError(err) + return nil, errs.Wrap(err) + } + if !isAuthorized { + b.AuditLogNotAuthorized(ae) + return nil, errs.ErrAuthorizationFailed + } + + filesPath := strings.TrimSuffix(b.FilePath, "/") + backupDir := filepath.Join(filesPath, "backups") + + // check if backup directory exists + if _, err := os.Stat(backupDir); os.IsNotExist(err) { + return []BackupFile{}, nil // return empty list if no backups directory + } + + // read backup directory + files, err := os.ReadDir(backupDir) + if err != nil { + return nil, errs.Wrap(err) + } + + var backupFiles []BackupFile + for _, file := range files { + if strings.HasPrefix(file.Name(), "backup-") && strings.HasSuffix(file.Name(), ".tar.gz") { + info, err := file.Info() + if err != nil { + continue + } + + backupFiles = append(backupFiles, BackupFile{ + Name: file.Name(), + Size: info.Size(), + CreatedAt: info.ModTime(), + RelativePath: filepath.Join("backups", file.Name()), + }) + } + } + + // sort by creation time (newest first) + for i := 0; i < len(backupFiles)-1; i++ { + for j := i + 1; j < len(backupFiles); j++ { + if backupFiles[i].CreatedAt.Before(backupFiles[j].CreatedAt) { + backupFiles[i], backupFiles[j] = backupFiles[j], backupFiles[i] + } + } + } + + ae.Details["backupCount"] = len(backupFiles) + b.AuditLogAuthorized(ae) + return backupFiles, nil +} + +// GetBackupFile returns a file handle to a backup file if it exists and is valid +func (b *Backup) GetBackupFile( + ctx context.Context, + session *model.Session, + filename string, +) (*os.File, error) { + ae := NewAuditEvent("Backup.DownloadBackup", session) + ae.Details["filename"] = filename + + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil { + b.LogAuthError(err) + return nil, errs.Wrap(err) + } + if !isAuthorized { + b.AuditLogNotAuthorized(ae) + return nil, errs.ErrAuthorizationFailed + } + + // validate filename - must be a backup file + if !strings.HasPrefix(filename, "backup-") || !strings.HasSuffix(filename, ".tar.gz") { + b.Logger.Debugw("invalid backup filename format", "filename", filename) + return nil, validate.WrapErrorWithField(errors.New("invalid backup filename"), "filename") + } + + // get backup directory path + filesPath := strings.TrimSuffix(b.FilePath, "/") + backupDir := filepath.Join(filesPath, "backups") + + // use os.OpenRoot for secure file access within backup directory + root, err := os.OpenRoot(backupDir) + if err != nil { + return nil, errs.Wrap(err) + } + defer root.Close() + + // try to stat the file using the secure root - this prevents directory traversal + info, err := root.Stat(filename) + if err != nil { + if os.IsNotExist(err) { + b.Logger.Debugw("backup file not found", "filename", filename) + return nil, gorm.ErrRecordNotFound + } + return nil, errs.Wrap(err) + } + + if !info.Mode().IsRegular() { + b.Logger.Debugw("requested file is not a regular file", "filename", filename) + return nil, validate.WrapErrorWithField(errors.New("not a regular file"), "filename") + } + + // open the file using the secure root - this maintains security throughout + file, err := root.Open(filename) + if err != nil { + return nil, errs.Wrap(err) + } + + ae.Details["backupSize"] = info.Size() + b.AuditLogAuthorized(ae) + return file, nil +} diff --git a/backend/service/campaign.go b/backend/service/campaign.go index 52da103..a2a4c51 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -1,17 +1,18 @@ package service import ( + "bytes" "context" "crypto/tls" "errors" "fmt" - "html/template" "math/rand" "os" "path/filepath" "slices" "sort" "strings" + "text/template" "time" go_errors "github.com/go-errors/errors" @@ -766,7 +767,6 @@ func (c *Campaign) GetStats( if err != nil { return nil, errs.Wrap(err) } - // no audit on read return &model.CampaignsStatView{ Active: active, Upcoming: upcoming, @@ -1952,11 +1952,13 @@ func (c *Campaign) sendCampaignMessages( email, nil, ) - err = m.SetBodyHTMLTemplate(mailTmpl, t) + var bodyBuffer bytes.Buffer + err = mailTmpl.Execute(&bodyBuffer, t) if err != nil { - c.Logger.Errorw("failed to set body html template", "error", err) + c.Logger.Errorw("failed to execute mail template", "error", err) return errs.Wrap(err) } + m.SetBodyString("text/html", bodyBuffer.String()) // attachments attachments := email.Attachments for _, attachment := range attachments { @@ -2962,6 +2964,449 @@ func (c *Campaign) AnonymizeByID( return nil } +// SendEmailByCampaignRecipientID sends an email to a specific campaign recipient +// Multiple sends to the same recipient are allowed to support retry scenarios and follow-ups. +func (c *Campaign) SendEmailByCampaignRecipientID( + ctx context.Context, + session *model.Session, + campaignRecipientID *uuid.UUID, +) error { + ae := NewAuditEvent("Campaign.SendEmailByCampaignRecipientID", session) + ae.Details["campaignRecipientId"] = campaignRecipientID.String() + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + c.LogAuthError(err) + return errs.Wrap(err) + } + if !isAuthorized { + c.AuditLogNotAuthorized(ae) + return errs.ErrAuthorizationFailed + } + + // get campaign recipient + campaignRecipient, err := c.CampaignRecipientRepository.GetByID( + ctx, + campaignRecipientID, + &repository.CampaignRecipientOption{ + WithRecipient: true, + WithCampaign: true, + }, + ) + if err != nil { + c.Logger.Errorw("failed to get campaign recipient by id", "error", err) + return errs.Wrap(err) + } + + campaign := campaignRecipient.Campaign + if campaign == nil { + return errors.New("campaign recipient has no campaign loaded") + } + + // check if campaign is active + if !campaign.IsActive() { + return errors.New("campaign is not active") + } + + // check if recipient exists (not anonymized) + if campaignRecipient.Recipient == nil { + return errors.New("recipient is anonymized or deleted") + } + + // check if cancelled + if !campaignRecipient.CancelledAt.IsNull() { + return errors.New("recipient has been cancelled") + } + + campaignID := campaign.ID.MustGet() + + // add resend information to audit log + isResend := !campaignRecipient.SentAt.IsNull() + ae.Details["isResend"] = isResend + if isResend { + ae.Details["previouslySentAt"] = campaignRecipient.SentAt.MustGet().Format(time.RFC3339) + } + + // send the email using existing logic from sendCampaignMessages + err = c.sendSingleCampaignMessage(ctx, session, &campaignID, campaignRecipient) + if err != nil { + c.Logger.Errorw("failed to send campaign message", "error", err) + return errs.Wrap(err) + } + + c.AuditLogAuthorized(ae) + return nil +} + +// sendSingleCampaignMessage sends an email to a single campaign recipient +func (c *Campaign) sendSingleCampaignMessage( + ctx context.Context, + session *model.Session, + campaignID *uuid.UUID, + campaignRecipient *model.CampaignRecipient, +) error { + // get campaign template details - similar logic from sendCampaignMessages + campaign, err := c.CampaignRepository.GetByID( + ctx, + campaignID, + &repository.CampaignOption{}, + ) + if err != nil { + c.Logger.Errorw("failed to get campaign by id", "error", err) + return errs.Wrap(err) + } + + templateID, err := campaign.TemplateID.Get() + if err != nil { + return errors.New("campaign has no template") + } + + cTemplate, err := c.CampaignTemplateService.GetByID( + ctx, + session, + &templateID, + &repository.CampaignTemplateOption{ + WithDomain: true, + WithSMTPConfiguration: true, + WithIdentifier: true, + }, + ) + if err != nil { + c.Logger.Errorw("failed to get campaign template by id", "error", err) + return errs.Wrap(err) + } + + // check domain + domain := cTemplate.Domain + if domain == nil { + return errors.New("campaign template has no domain") + } + + // get email details + emailID, err := cTemplate.EmailID.Get() + if err != nil { + return errors.New("campaign template has no email") + } + + email, err := c.MailService.GetByID(ctx, session, &emailID) + if err != nil { + c.Logger.Errorw("failed to get email by id", "error", err) + return errs.Wrap(err) + } + + // update last attempt timestamp + campaignRecipientID := campaignRecipient.ID.MustGet() + campaignRecipient.LastAttemptAt = nullable.NewNullableWithValue(time.Now()) + err = c.CampaignRecipientRepository.UpdateByID(ctx, &campaignRecipientID, campaignRecipient) + if err != nil { + c.Logger.Errorw("failed to update last attempted at", "error", err) + return errs.Wrap(err) + } + + // prepare template for rendering + content, err := email.Content.Get() + if err != nil { + return errors.New("failed to get email content") + } + + t := template.New("email") + t = t.Funcs(TemplateFuncs()) + mailTmpl, err := t.Parse(content.String()) + if err != nil { + return errs.Wrap(err) + } + + // check sending method + isSmtpCampaign := cTemplate.SMTPConfigurationID.IsSpecified() && !cTemplate.SMTPConfigurationID.IsNull() + isAPISenderCampaign := cTemplate.APISenderID.IsSpecified() && !cTemplate.APISenderID.IsNull() + + if !isSmtpCampaign && !isAPISenderCampaign { + return errors.New("campaign template has no SMTP configuration or API sender") + } + + if isAPISenderCampaign { + // send via API + err = c.APISenderService.Send( + ctx, + session, + cTemplate, + campaignRecipient, + domain, + mailTmpl, + email, + ) + } else { + // send via SMTP + err = c.sendSingleEmailSMTP(ctx, session, cTemplate, campaignRecipient, domain, mailTmpl, email) + } + + // save sending result + saveErr := c.saveSendingResult(ctx, campaignRecipient, err) + if saveErr != nil { + c.Logger.Errorw("failed to save sending result", "error", saveErr) + return errs.Wrap(saveErr) + } + + return err +} + +// sendSingleEmailSMTP sends an email to a single recipient via SMTP +func (c *Campaign) sendSingleEmailSMTP( + ctx context.Context, + session *model.Session, + cTemplate *model.CampaignTemplate, + campaignRecipient *model.CampaignRecipient, + domain *model.Domain, + mailTmpl *template.Template, + email *model.Email, +) error { + // get SMTP configuration + smtpConfigID, err := cTemplate.SMTPConfigurationID.Get() + if err != nil { + return errors.New("failed to get SMTP configuration from template") + } + + smtpConfig, err := c.SMTPConfigService.GetByID( + ctx, + session, // use the actual session passed to the method + &smtpConfigID, + &repository.SMTPConfigurationOption{ + WithHeaders: true, + }, + ) + if err != nil { + c.Logger.Errorw("smtp configuration did not load", "error", err) + return errs.Wrap(err) + } + + smtpPort, err := smtpConfig.Port.Get() + if err != nil { + return errs.Wrap(err) + } + + smtpHost, err := smtpConfig.Host.Get() + if err != nil { + return errs.Wrap(err) + } + + smtpIgnoreCertErrors, err := smtpConfig.IgnoreCertErrors.Get() + if err != nil { + return errs.Wrap(err) + } + + // setup SMTP client options + emailOptions := []mail.Option{ + mail.WithPort(smtpPort.Int()), + mail.WithTLSConfig( + &tls.Config{ + ServerName: smtpHost.String(), + InsecureSkipVerify: smtpIgnoreCertErrors, + }, + ), + } + + // setup authentication if provided + username, err := smtpConfig.Username.Get() + if err != nil { + return errs.Wrap(err) + } + password, err := smtpConfig.Password.Get() + if err != nil { + return errs.Wrap(err) + } + + if un := username.String(); len(un) > 0 { + emailOptions = append(emailOptions, mail.WithUsername(un)) + if pw := password.String(); len(pw) > 0 { + emailOptions = append(emailOptions, mail.WithPassword(pw)) + } + } + + // create message + messageOptions := []mail.MsgOption{ + mail.WithNoDefaultUserAgent(), + } + m := mail.NewMsg(messageOptions...) + + // set envelope from + err = m.EnvelopeFrom(email.MailEnvelopeFrom.MustGet().String()) + if err != nil { + c.Logger.Errorw("failed to set envelope from", "error", err) + return errs.Wrap(err) + } + + // set headers + err = m.From(email.MailHeaderFrom.MustGet().String()) + if err != nil { + c.Logger.Errorw("failed to set mail header 'From'", "error", err) + return errs.Wrap(err) + } + + recpEmail := campaignRecipient.Recipient.Email.MustGet().String() + err = m.To(recpEmail) + if err != nil { + c.Logger.Errorw("failed to set mail header 'To'", "error", err) + return errs.Wrap(err) + } + + // custom headers + if headers := smtpConfig.Headers; headers != nil { + for _, header := range headers { + key := header.Key.MustGet() + value := header.Value.MustGet() + m.SetGenHeader( + mail.Header(key.String()), + value.String(), + ) + } + } + + m.Subject(email.MailHeaderSubject.MustGet().String()) + + // setup template variables + domainName, err := domain.Name.Get() + if err != nil { + return errs.Wrap(err) + } + + urlIdentifier := cTemplate.URLIdentifier + if urlIdentifier == nil { + return errors.New("url identifier must be loaded for the campaign template") + } + + urlPath := cTemplate.URLPath.MustGet().String() + t := c.TemplateService.CreateMail( + domainName.String(), + urlIdentifier.Name.MustGet(), + urlPath, + campaignRecipient, + email, + nil, + ) + + var bodyBuffer bytes.Buffer + err = mailTmpl.Execute(&bodyBuffer, t) + if err != nil { + c.Logger.Errorw("failed to execute mail template", "error", err) + return errs.Wrap(err) + } + m.SetBodyString("text/html", bodyBuffer.String()) + + // handle attachments + attachments := email.Attachments + for _, attachment := range attachments { + p, err := c.MailService.AttachmentService.GetPath(attachment) + if err != nil { + return fmt.Errorf("failed to get attachment path: %s", err) + } + if !attachment.EmbeddedContent.MustGet() { + m.AttachFile(p.String()) + } else { + attachmentContent, err := os.ReadFile(p.String()) + if err != nil { + return errs.Wrap(err) + } + // setup attachment for executing as email template + attachmentAsEmail := model.Email{ + ID: email.ID, + CreatedAt: email.CreatedAt, + UpdatedAt: email.UpdatedAt, + Name: email.Name, + MailEnvelopeFrom: email.MailEnvelopeFrom, + MailHeaderFrom: email.MailHeaderFrom, + MailHeaderSubject: email.MailHeaderSubject, + Content: email.Content, + AddTrackingPixel: email.AddTrackingPixel, + CompanyID: email.CompanyID, + Attachments: email.Attachments, + Company: email.Company, + } + attachmentAsEmail.Content = nullable.NewNullableWithValue( + *vo.NewUnsafeOptionalString1MB(string(attachmentContent)), + ) + attachmentStr, err := c.TemplateService.CreateMailBody( + urlIdentifier.Name.MustGet(), + urlPath, + domain, + campaignRecipient, + &attachmentAsEmail, + nil, + ) + if err != nil { + return errs.Wrap(fmt.Errorf("failed to setup attachment with embedded content: %s", err)) + } + m.AttachReadSeeker( + filepath.Base(p.String()), + strings.NewReader(attachmentStr), + ) + } + } + + // send the email + var mc *mail.Client + + // try different authentication methods + if un := username.String(); len(un) > 0 { + // try CRAM-MD5 first when credentials are provided + emailOptionsCRAM5 := append(emailOptions, mail.WithSMTPAuth(mail.SMTPAuthCramMD5)) + mc, _ = mail.NewClient(smtpHost.String(), emailOptionsCRAM5...) + mc.SetLogger(log.NewGoMailLoggerAdapter(c.Logger)) + mc.SetDebugLog(true) + if build.Flags.Production { + mc.SetTLSPolicy(mail.TLSMandatory) + } else { + mc.SetTLSPolicy(mail.TLSOpportunistic) + } + err = mc.DialAndSendWithContext(ctx, m) + + // check if it's an authentication error and try PLAIN auth + if err != nil && (strings.Contains(err.Error(), "535 ") || + strings.Contains(err.Error(), "534 ") || + strings.Contains(err.Error(), "538 ") || + strings.Contains(err.Error(), "CRAM-MD5") || + strings.Contains(err.Error(), "authentication failed")) { + c.Logger.Debugf("CRAM-MD5 authentication failed, trying PLAIN auth", "error", err) + emailOptionsBasic := emailOptions + emailOptionsBasic = append(emailOptions, mail.WithSMTPAuth(mail.SMTPAuthPlain)) + mc, _ = mail.NewClient(smtpHost.String(), emailOptionsBasic...) + mc.SetLogger(log.NewGoMailLoggerAdapter(c.Logger)) + mc.SetDebugLog(true) + if build.Flags.Production { + mc.SetTLSPolicy(mail.TLSMandatory) + } else { + mc.SetTLSPolicy(mail.TLSOpportunistic) + } + err = mc.DialAndSendWithContext(ctx, m) + } + } else { + // no credentials provided, try without authentication + mc, _ = mail.NewClient(smtpHost.String(), emailOptions...) + mc.SetLogger(log.NewGoMailLoggerAdapter(c.Logger)) + mc.SetDebugLog(true) + if build.Flags.Production { + mc.SetTLSPolicy(mail.TLSMandatory) + } else { + mc.SetTLSPolicy(mail.TLSOpportunistic) + } + err = mc.DialAndSendWithContext(ctx, m) + + // if no-auth fails and we get an auth-related error, log it appropriately + if err != nil && (strings.Contains(err.Error(), "530 ") || + strings.Contains(err.Error(), "535 ") || + strings.Contains(err.Error(), "authentication required") || + strings.Contains(err.Error(), "AUTH")) { + c.Logger.Warnw("Server requires authentication but no credentials provided", "error", err) + } + } + + if err != nil { + c.Logger.Errorw("failed to send email", "error", err) + return errs.Wrap(err) + } + + return nil +} + // SetNotableCampaignEvent checks and update if most notable event for a campaign func (c *Campaign) setMostNotableCampaignEvent( ctx context.Context, @@ -3051,11 +3496,13 @@ func (c *Campaign) GenerateCampaignStats(ctx context.Context, session *model.Ses openRate := float64(0) clickRate := float64(0) submissionRate := float64(0) + reportRate := float64(0) if resultStats.Recipients > 0 { openRate = (float64(resultStats.TrackingPixelLoaded) / float64(resultStats.Recipients)) * 100 clickRate = (float64(resultStats.WebsiteLoaded) / float64(resultStats.Recipients)) * 100 submissionRate = (float64(resultStats.SubmittedData) / float64(resultStats.Recipients)) * 100 + reportRate = (float64(resultStats.Reported) / float64(resultStats.Recipients)) * 100 } // Determine campaign type @@ -3116,9 +3563,11 @@ func (c *Campaign) GenerateCampaignStats(ctx context.Context, session *model.Ses TrackingPixelLoaded: int(resultStats.TrackingPixelLoaded), WebsiteVisits: int(resultStats.WebsiteLoaded), DataSubmissions: int(resultStats.SubmittedData), + Reported: int(resultStats.Reported), OpenRate: openRate, ClickRate: clickRate, SubmissionRate: submissionRate, + ReportRate: reportRate, TemplateName: templateName, CampaignType: campaignType, @@ -3184,3 +3633,265 @@ func (c *Campaign) GetAllCampaignStats(ctx context.Context, session *model.Sessi return result, nil } + +// ProcessReportedCSV processes a CSV file with reported recipients +func (c *Campaign) ProcessReportedCSV( + ctx context.Context, + session *model.Session, + campaignID *uuid.UUID, + records [][]string, +) (int, int, error) { + ae := NewAuditEvent("Campaign.ProcessReportedCSV", session) + ae.Details["campaignID"] = campaignID.String() + + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + c.LogAuthError(err) + return 0, 0, errs.Wrap(err) + } + if !isAuthorized { + c.AuditLogNotAuthorized(ae) + return 0, 0, errs.ErrAuthorizationFailed + } + + // get campaign to check it exists and get details + campaign, err := c.CampaignRepository.GetByID(ctx, campaignID, &repository.CampaignOption{}) + if err != nil { + c.Logger.Errorw("failed to get campaign by id", "error", err) + return 0, 0, errs.Wrap(err) + } + + // validate CSV headers + headers := records[0] + reportedByIndex := -1 + dateReportedIndex := -1 + + c.Logger.Debugw("processing CSV headers", "headers", headers) + + for i, header := range headers { + switch strings.ToLower(strings.TrimSpace(header)) { + case "reported by": + reportedByIndex = i + c.Logger.Debugw("found reported by column", "index", i) + case "date reporter (utc+02:00)", "date reported(utc+02:00)", "date reported", "date reporter": + dateReportedIndex = i + c.Logger.Debugw("found date column", "index", i, "header", header) + } + } + + if reportedByIndex == -1 { + c.Logger.Errorw("CSV missing required column", "expected", "reported by", "headers", headers) + return 0, 0, errs.NewValidationError(errors.New("CSV must have 'reported by' column")) + } + if dateReportedIndex == -1 { + c.Logger.Errorw("CSV missing required date column", "expected", "date reported(utc+02:00)", "headers", headers) + return 0, 0, errs.NewValidationError(errors.New("CSV must have 'date reporter (utc+02:00)' or similar date column")) + } + + processed := 0 + skipped := 0 + reportedEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_REPORTED] + + // process each row + for i, record := range records[1:] { // skip header + if len(record) <= reportedByIndex || len(record) <= dateReportedIndex { + skipped++ + c.Logger.Debugw("skipping row with insufficient columns", "row", i+2) + continue + } + + reportedByEmail := strings.TrimSpace(record[reportedByIndex]) + dateReported := strings.TrimSpace(record[dateReportedIndex]) + + if reportedByEmail == "" { + skipped++ + c.Logger.Debugw("skipping row with empty email", "row", i+2) + continue + } + // parse date - try multiple formats and handle timezone + var parsedDate time.Time + dateFormats := []string{ + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + "2006-01-02T15:04:05Z", + "2006-01-02T15:04:05-07:00", + "2006-01-02T15:04:05+02:00", + "2006-01-02", + "01/02/2006 15:04:05", + "01/02/2006", + "02-01-2006 15:04:05", + "02-01-2006", + } + + dateParseError := true + for _, format := range dateFormats { + if pd, err := time.Parse(format, dateReported); err == nil { + // if the parsed date has no timezone info and the header mentions UTC+02:00, + // assume the time is in UTC+02:00 and convert to UTC + if pd.Location() == time.UTC && strings.Contains(strings.ToLower(headers[dateReportedIndex]), "utc+02:00") { + // treat as UTC+02:00 and convert to UTC + loc, _ := time.LoadLocation("Europe/Berlin") // UTC+2 (or use FixedZone) + if loc != nil { + pd = time.Date(pd.Year(), pd.Month(), pd.Day(), pd.Hour(), pd.Minute(), pd.Second(), pd.Nanosecond(), loc).UTC() + } + } + parsedDate = pd + dateParseError = false + break + } + } + + if dateParseError { + skipped++ + c.Logger.Debugw("skipping row with invalid date format", "row", i+2, "date", dateReported, "tried_formats", dateFormats) + continue + } + + c.Logger.Debugw("processing row", "row", i+2, "email", reportedByEmail, "date", parsedDate) + + // find recipient by email in this campaign + emailVO, err := vo.NewEmail(reportedByEmail) + if err != nil { + skipped++ + c.Logger.Debugw("invalid email format", "email", reportedByEmail, "row", i+2) + continue + } + + // Get campaign to check company context + companyID, _ := campaign.CompanyID.Get() + var companyPtr *uuid.UUID + if companyID != uuid.Nil { + companyPtr = &companyID + } + + recipient, err := c.RecipientService.GetByEmail(ctx, session, emailVO, companyPtr) + if err != nil { + skipped++ + c.Logger.Debugw("recipient not found for email", "email", reportedByEmail, "row", i+2) + continue + } + + recipientID := recipient.ID.MustGet() + + // check if recipient is part of this campaign + campaignRecipient, err := c.CampaignRecipientRepository.GetByCampaignAndRecipientID( + ctx, + campaignID, + &recipientID, + &repository.CampaignRecipientOption{}, + ) + if err != nil { + skipped++ + c.Logger.Debugw("recipient not part of campaign", "email", reportedByEmail, "campaignID", campaignID.String(), "row", i+2) + continue + } + + // check if already reported (to avoid duplicates) + existingEvent, err := c.CampaignRepository.GetEventsByCampaignID( + ctx, + campaignID, + &repository.CampaignEventOption{ + QueryArgs: &vo.QueryArgs{ + Limit: 1, + }, + EventTypeIDs: []string{reportedEventID.String()}, + }, + nil, + ) + + alreadyReported := false + if err == nil && existingEvent != nil { + for _, event := range existingEvent.Rows { + if event.RecipientID != nil && *event.RecipientID == recipientID { + alreadyReported = true + break + } + } + } + + if alreadyReported { + skipped++ + c.Logger.Debugw("recipient already reported", "email", reportedByEmail, "campaignID", campaignID.String()) + continue + } + + // create campaign event for reported + eventID := uuid.New() + + var campaignEvent *model.CampaignEvent + if campaign.IsAnonymous.MustGet() { + campaignEvent = &model.CampaignEvent{ + ID: &eventID, + CampaignID: campaignID, + RecipientID: nil, + IP: vo.NewEmptyOptionalString64(), + UserAgent: vo.NewEmptyOptionalString255(), + EventID: reportedEventID, + Data: vo.NewEmptyOptionalString1MB(), + } + } else { + campaignEvent = &model.CampaignEvent{ + ID: &eventID, + CampaignID: campaignID, + RecipientID: &recipientID, + IP: vo.NewEmptyOptionalString64(), + UserAgent: vo.NewEmptyOptionalString255(), + EventID: reportedEventID, + Data: vo.NewEmptyOptionalString1MB(), + } + } + + // save the event with custom timestamp + err = c.saveReportedEvent(campaignEvent, parsedDate) + if err != nil { + c.Logger.Errorw("failed to save reported event", "error", err, "email", reportedByEmail) + skipped++ + continue + } + + // update most notable event for campaign recipient + err = c.SetNotableCampaignRecipientEvent( + ctx, + campaignRecipient, + data.EVENT_CAMPAIGN_RECIPIENT_REPORTED, + ) + if err != nil { + c.Logger.Errorw("failed to update notable event", "error", err) + } + + processed++ + } + + ae.Details["processed"] = processed + ae.Details["skipped"] = skipped + c.AuditLogAuthorized(ae) + + return processed, skipped, nil +} + +// saveReportedEvent saves a reported event with custom timestamp +func (c *Campaign) saveReportedEvent( + campaignEvent *model.CampaignEvent, + customTime time.Time, +) error { + row := map[string]any{ + "id": campaignEvent.ID.String(), + "event_id": campaignEvent.EventID.String(), + "campaign_id": campaignEvent.CampaignID.String(), + "ip_address": campaignEvent.IP.String(), + "user_agent": campaignEvent.UserAgent.String(), + "data": campaignEvent.Data.String(), + "created_at": customTime, + "updated_at": time.Now(), + } + if campaignEvent.RecipientID != nil { + row["recipient_id"] = campaignEvent.RecipientID.String() + } + + res := c.CampaignRepository.DB.Model(&database.CampaignEvent{}).Create(row) + if res.Error != nil { + return res.Error + } + return nil +} diff --git a/backend/service/domain.go b/backend/service/domain.go index 6954fc1..06c1891 100644 --- a/backend/service/domain.go +++ b/backend/service/domain.go @@ -36,6 +36,7 @@ type Domain struct { CampaignTemplateService *CampaignTemplate AssetService *Asset FileService *File + TemplateService *Template } // Create creates a new domain @@ -60,6 +61,19 @@ func (d *Domain) Create( // 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") + } + } + 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 name := domain.Name.MustGet() // safe as we have validated _, err = d.DomainRepository.GetByName( @@ -403,9 +417,19 @@ func (d *Domain) UpdateByID( 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 { diff --git a/backend/service/email.go b/backend/service/email.go index a2139d8..68caffd 100644 --- a/backend/service/email.go +++ b/backend/service/email.go @@ -1,13 +1,14 @@ package service import ( + "bytes" "context" "crypto/tls" "fmt" - "html/template" "os" "path/filepath" "strings" + "text/template" "github.com/go-errors/errors" @@ -171,6 +172,13 @@ func (m *Email) Create( if err := email.Validate(); err != nil { return nil, errs.Wrap(err) } + // validate template content if present + if content, err := email.Content.Get(); err == nil { + if err := m.TemplateService.ValidateEmailTemplate(content.String()); err != nil { + m.Logger.Errorw("failed to validate email template", "error", err) + return nil, validate.WrapErrorWithField(errors.New("invalid template: "+err.Error()), "content") + } + } // check uniqueness var companyID *uuid.UUID if cid, err := email.CompanyID.Get(); err == nil { @@ -590,11 +598,13 @@ func (m *Email) SendTestEmail( email, nil, ) - err = msg.SetBodyHTMLTemplate(mailTmpl, t) + var bodyBuffer bytes.Buffer + err = mailTmpl.Execute(&bodyBuffer, t) if err != nil { - m.Logger.Errorw("failed to set body html template", "error", err) - return errs.Wrap(err) + m.Logger.Errorw("failed to execute mail template", "error", err) + return err } + msg.SetBodyString("text/html", bodyBuffer.String()) // attachments attachments := email.Attachments for _, attachment := range attachments { @@ -780,6 +790,11 @@ func (m *Email) UpdateByID( current.MailHeaderSubject.Set(v) } if v, err := email.Content.Get(); err == nil { + // validate template content before updating + if err := m.TemplateService.ValidateEmailTemplate(v.String()); err != nil { + m.Logger.Errorw("failed to validate email template", "error", err) + return validate.WrapErrorWithField(errors.New("invalid template: "+err.Error()), "content") + } if _, err := email.AddTrackingPixel.Get(); err == nil { // handle tracking pixel email, err = m.toggleTrackingPixel(email) diff --git a/backend/service/page.go b/backend/service/page.go index 2fe46d2..ff7b98c 100644 --- a/backend/service/page.go +++ b/backend/service/page.go @@ -20,6 +20,7 @@ type Page struct { PageRepository *repository.Page CampaignRepository *repository.Campaign CampaignTemplateService *CampaignTemplate + TemplateService *Template } // Create creates a new page @@ -49,6 +50,13 @@ 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") + } + } // check uniqueness name := page.Name.MustGet() isOK, err := repository.CheckNameIsUnique( @@ -253,6 +261,11 @@ func (p *Page) UpdateByID( current.Name.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) } // update page diff --git a/backend/service/templateService.go b/backend/service/templateService.go index 8514b76..d82e4d5 100644 --- a/backend/service/templateService.go +++ b/backend/service/templateService.go @@ -5,10 +5,10 @@ import ( "encoding/base64" "fmt" "html" - "html/template" "io" "math/rand" "strings" + "text/template" "time" "github.com/go-errors/errors" @@ -18,6 +18,7 @@ import ( "github.com/phishingclub/phishingclub/errs" "github.com/phishingclub/phishingclub/model" "github.com/phishingclub/phishingclub/utils" + "github.com/phishingclub/phishingclub/vo" "github.com/yeqown/go-qrcode/v2" ) @@ -57,20 +58,113 @@ func (t *Template) CreateMail( baseURL, campaignRecipient.ID.MustGet().String(), ) - // #nosec - trackingPixelMarkup := template.HTML(trackingPixel) return t.newTemplateDataMap( idKey, baseURL, url, campaignRecipient.Recipient, trackingPixelPath, - trackingPixelMarkup, + trackingPixel, email, apiSender, ) } +// ValidatePageTemplate validates that a page template can be parsed and executed without errors +func (t *Template) ValidatePageTemplate(content string) error { + _, err := template.New("validation"). + Funcs(TemplateFuncs()). + Parse(content) + + if err != nil { + return fmt.Errorf("failed to parse page template: %s", err) + } + + // also try to execute with mock data to catch runtime errors + _, err = t.ApplyPageMock(content) + if err != nil { + return fmt.Errorf("failed to execute page template: %s", err) + } + + return nil +} + +// ValidateEmailTemplate validates that an email template can be parsed and executed without errors +func (t *Template) ValidateEmailTemplate(content string) error { + _, err := template.New("validation"). + Funcs(TemplateFuncs()). + Parse(content) + + if err != nil { + return fmt.Errorf("failed to parse email template: %s", err) + } + + // also try to execute with mock data to catch runtime errors + domain := &model.Domain{ + Name: nullable.NewNullableWithValue( + *vo.NewString255Must("example.test"), + ), + } + recipient := model.NewRecipientExample() + campaignRecipient := model.CampaignRecipient{ + ID: nullable.NewNullableWithValue( + uuid.New(), + ), + Recipient: recipient, + } + email := model.NewEmailExample() + email.Content = nullable.NewNullableWithValue( + *vo.NewUnsafeOptionalString1MB(content), + ) + apiSender := model.NewAPISenderExample() + + _, err = t.CreateMailBody( + "id", + "/test", + domain, + &campaignRecipient, + email, + apiSender, + ) + if err != nil { + return fmt.Errorf("failed to execute email template: %s", err) + } + + return nil +} + +// ValidateDomainTemplate validates that a domain template can be parsed and executed without errors +func (t *Template) ValidateDomainTemplate(content string) error { + _, err := template.New("validation"). + Funcs(TemplateFuncs()). + Parse(content) + + if err != nil { + return fmt.Errorf("failed to parse domain template: %s", err) + } + + // also try to execute with mock data to catch runtime errors + // domains only have access to BaseURL variable + data := map[string]any{ + "BaseURL": "https://example.test", + } + + tmpl, err := template.New("domain"). + Funcs(TemplateFuncs()). + Parse(content) + if err != nil { + return fmt.Errorf("failed to parse domain template: %s", err) + } + + var buf bytes.Buffer + err = tmpl.Execute(&buf, data) + if err != nil { + return fmt.Errorf("failed to execute domain template: %s", err) + } + + return nil +} + // ApplyPageMock func (t *Template) ApplyPageMock(content string) (*bytes.Buffer, error) { // build response @@ -123,7 +217,6 @@ func (t *Template) CreateMailBody( email, apiSender, ) - // parse and execute the mail content mailContentTemplate := template.New("mailContent") mailContentTemplate = mailContentTemplate.Funcs(TemplateFuncs()) content, err := email.Content.Get() @@ -194,14 +287,14 @@ func (t *Template) CreatePhishingPage( return w, nil } -// newTemplateDataMap creates a new data map for the templates +// newTemplateDataMap creates a new data map for templates func (t *Template) newTemplateDataMap( id string, baseURL string, url string, recipient *model.Recipient, trackingPixelPath string, - trackingPixelMarkup template.HTML, + trackingPixelMarkup string, email *model.Email, apiSender *model.APISender, ) *map[string]any { @@ -287,6 +380,38 @@ func (t *Template) newTemplateDataMap( return &m } +// TemplateFuncs returns template functions for templates +func TemplateFuncs() template.FuncMap { + return template.FuncMap{ + "urlEscape": func(s string) string { + return template.URLQueryEscaper(s) + }, + "htmlEscape": func(s string) string { + return html.EscapeString(s) + }, + "randInt": func(n1, n2 int) (int, error) { + if n1 > n2 { + return 0, fmt.Errorf("first number must be less than or equal to second number") + } + return rand.Intn(n2-n1+1) + n1, nil + }, + "randAlpha": RandAlpha, + "qr": GenerateQRCode, + "date": func(format string, offsetSeconds ...int) string { + offset := 0 + if len(offsetSeconds) > 0 { + offset = offsetSeconds[0] + } + targetTime := time.Now().Add(time.Duration(offset) * time.Second) + goFormat := convertDateFormat(format) + return targetTime.Format(goFormat) + }, + "base64": func(s string) string { + return base64.StdEncoding.EncodeToString([]byte(s)) + }, + } +} + func (t *Template) AddTrackingPixel(content string) string { if strings.Contains(content, trackingPixelTemplate) { return content @@ -406,39 +531,7 @@ func (t *Template) RemoveTrackingPixelFromContent(content string) string { return strings.ReplaceAll(content, trackingPixelTemplate, "") } -func TemplateFuncs() template.FuncMap { - return template.FuncMap{ - "urlEscape": func(s string) string { - return template.URLQueryEscaper(s) - }, - "htmlEscape": func(s string) string { - return html.EscapeString(s) - }, - "randInt": func(n1, n2 int) (int, error) { - if n1 > n2 { - return 0, fmt.Errorf("first number must be less than or equal to second number") - } - // #nosec - return rand.Intn(n2-n1+1) + n1, nil - }, - "randAlpha": RandAlpha, - "qr": GenerateQRCode, - "date": func(format string, offsetSeconds ...int) string { - offset := 0 - if len(offsetSeconds) > 0 { - offset = offsetSeconds[0] - } - targetTime := time.Now().Add(time.Duration(offset) * time.Second) - goFormat := convertDateFormat(format) - return targetTime.Format(goFormat) - }, - "base64": func(s string) string { - return base64.StdEncoding.EncodeToString([]byte(s)) - }, - } -} - -func GenerateQRCode(args ...any) (template.HTML, error) { +func GenerateQRCode(args ...any) (string, error) { if len(args) == 0 { return "", errors.New("URL is required") } @@ -465,8 +558,7 @@ func GenerateQRCode(args ...any) (template.HTML, error) { if err := qr.Save(writer); err != nil { return "", err } - // #nosec - return template.HTML(buf.String()), nil + return buf.String(), nil } const alphaChar = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" diff --git a/backend/testfiles/reporters.csv b/backend/testfiles/reporters.csv new file mode 100644 index 0000000..9f51e00 --- /dev/null +++ b/backend/testfiles/reporters.csv @@ -0,0 +1,2 @@ +Reported by,Date reported(UTC+02:00) +alice@black-boat.test,2025-09-17T20:11:24 diff --git a/frontend/src/app.css b/frontend/src/app.css index cc453f9..17761fc 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -45,9 +45,292 @@ src: url('/Phudu-Black.ttf') format('truetype'); } +/* custom properties for theming */ +:root { + /* light mode colors */ + --color-bg-primary: #ffffff; + --color-bg-secondary: #f8f9fa; + --color-bg-tertiary: #f1f3f4; + --color-border: #e5e7eb; + --color-border-hover: #d1d5db; + --color-text-primary: #111827; + --color-text-secondary: #6b7280; + --color-text-tertiary: #9ca3af; + --color-shadow: rgba(0, 0, 0, 0.1); + --color-shadow-lg: rgba(0, 0, 0, 0.15); + + /* form colors */ + --color-input-bg: #ffffff; + --color-input-border: #d1d5db; + --color-input-border-focus: #2563eb; + + /* scrollbar colors */ + --color-scrollbar-track: #f1f1f1; + --color-scrollbar-thumb: #819efb; + --color-scrollbar-thumb-hover: #6b85d6; +} + +.dark { + /* dark mode colors */ + --color-bg-primary: #111827; + --color-bg-secondary: #1f2937; + --color-bg-tertiary: #374151; + --color-border: #374151; + --color-border-hover: #4b5563; + --color-text-primary: #f9fafb; + --color-text-secondary: #d1d5db; + --color-text-tertiary: #9ca3af; + --color-shadow: rgba(0, 0, 0, 0.3); + --color-shadow-lg: rgba(0, 0, 0, 0.4); + + /* form colors */ + --color-input-bg: #374151; + --color-input-border: #4b5563; + --color-input-border-focus: #3b82f6; + + /* scrollbar colors */ + --color-scrollbar-track: #374151; + --color-scrollbar-thumb: #6b7280; + --color-scrollbar-thumb-hover: #9ca3af; +} + +/* global styles */ +body { + background-color: var(--color-bg-primary); + color: var(--color-text-primary); + transition: + background-color 0.2s ease, + color 0.2s ease; +} + +/* scrollbar styles */ body { @apply [&::-webkit-scrollbar]:w-2 - [&::-webkit-scrollbar-track]:bg-gray-100 + [&::-webkit-scrollbar-track]:bg-[var(--color-scrollbar-track)] [&::-webkit-scrollbar-thumb]:rounded-md - [&::-webkit-scrollbar-thumb]:bg-pc-dusty-light-blue; + [&::-webkit-scrollbar-thumb]:bg-[var(--color-scrollbar-thumb)] + [&::-webkit-scrollbar-thumb:hover]:bg-[var(--color-scrollbar-thumb-hover)]; +} + +/* dark mode utility classes */ +@layer utilities { + .bg-theme-primary { + background-color: var(--color-bg-primary); + } + + .bg-theme-secondary { + background-color: var(--color-bg-secondary); + } + + .bg-theme-tertiary { + background-color: var(--color-bg-tertiary); + } + + .text-theme-primary { + color: var(--color-text-primary); + } + + .text-theme-secondary { + color: var(--color-text-secondary); + } + + .text-theme-tertiary { + color: var(--color-text-tertiary); + } + + .border-theme { + border-color: var(--color-border); + } + + .border-theme-hover:hover { + border-color: var(--color-border-hover); + } + + .shadow-theme { + box-shadow: 0 1px 3px 0 var(--color-shadow); + } + + .shadow-theme-lg { + box-shadow: 0 10px 15px -3px var(--color-shadow-lg); + } + + .input-theme { + background-color: var(--color-input-bg); + border-color: var(--color-input-border); + color: var(--color-text-primary); + } + + .input-theme:focus { + border-color: var(--color-input-border-focus); + } +} + +/* component-specific dark mode overrides */ +.dark .bg-gray-50 { + @apply bg-gray-800; +} + +.dark .bg-gray-100 { + @apply bg-gray-700; +} + +.dark .bg-gray-200 { + @apply bg-gray-600; +} + +.dark .bg-white { + @apply bg-gray-800; +} + +.dark .text-gray-900 { + @apply text-gray-100; +} + +.dark .text-gray-800 { + @apply text-gray-200; +} + +.dark .text-gray-700 { + @apply text-gray-300; +} + +.dark .text-gray-600 { + @apply text-gray-400; +} + +.dark .text-black { + @apply text-white; +} + +.dark .border-gray-200 { + @apply border-gray-600; +} + +.dark .border-gray-300 { + @apply border-gray-500; +} + +/* table dark mode styles */ +.dark table { + @apply bg-gray-800; +} + +.dark table td { + @apply bg-transparent text-gray-300 border-gray-600; +} + +/* form elements dark mode */ +.dark input, +.dark textarea, +.dark select { + @apply bg-gray-700 border-gray-600 text-white placeholder-gray-400; +} + +.dark input:focus, +.dark textarea:focus, +.dark select:focus { + @apply border-blue-500 ring-blue-500; +} + +/* modal and card dark mode */ +.dark .modal-content, +.dark .card { + @apply bg-gray-800 border-gray-600; +} + +/* button hover effects in dark mode */ +.dark .bg-cta-blue:hover { + @apply bg-blue-600; +} + +.dark .bg-pc-darkblue:hover { + @apply bg-blue-900; +} + +/* custom scrollbar for dark mode containers */ +.dark .overflow-auto, +.dark .overflow-x-auto, +.dark .overflow-y-auto { + scrollbar-width: thin; + scrollbar-color: #6b7280 #374151; +} + +.dark .overflow-auto::-webkit-scrollbar, +.dark .overflow-x-auto::-webkit-scrollbar, +.dark .overflow-y-auto::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.dark .overflow-auto::-webkit-scrollbar-track, +.dark .overflow-x-auto::-webkit-scrollbar-track, +.dark .overflow-y-auto::-webkit-scrollbar-track { + background: #374151; +} + +.dark .overflow-auto::-webkit-scrollbar-thumb, +.dark .overflow-x-auto::-webkit-scrollbar-thumb, +.dark .overflow-y-auto::-webkit-scrollbar-thumb { + background: #6b7280; + border-radius: 4px; +} + +.dark .overflow-auto::-webkit-scrollbar-thumb:hover, +.dark .overflow-x-auto::-webkit-scrollbar-thumb:hover, +.dark .overflow-y-auto::-webkit-scrollbar-thumb:hover { + background: #9ca3af; +} + +/* native date picker dark mode styling */ +.dark input[type='date']::-webkit-calendar-picker-indicator, +.dark input[type='time']::-webkit-calendar-picker-indicator, +.dark input[type='datetime-local']::-webkit-calendar-picker-indicator { + filter: invert(1); +} + +.dark input[type='date']::-webkit-datetime-edit, +.dark input[type='time']::-webkit-datetime-edit, +.dark input[type='datetime-local']::-webkit-datetime-edit { + color: rgb(209 213 219); +} + +.dark input[type='date']::-webkit-datetime-edit-fields-wrapper, +.dark input[type='time']::-webkit-datetime-edit-fields-wrapper, +.dark input[type='datetime-local']::-webkit-datetime-edit-fields-wrapper { + background: rgb(55 65 81); +} + +/* force date picker to use system dark theme */ +.dark input[type='date'], +.dark input[type='time'], +.dark input[type='datetime-local'] { + color-scheme: dark; +} + +/* selected date background in dark mode */ +.dark input[type='date']::-webkit-datetime-edit-day-field:focus, +.dark input[type='date']::-webkit-datetime-edit-month-field:focus, +.dark input[type='date']::-webkit-datetime-edit-year-field:focus, +.dark input[type='time']::-webkit-datetime-edit-hour-field:focus, +.dark input[type='time']::-webkit-datetime-edit-minute-field:focus { + background-color: rgb(55 65 81); + color: rgb(209 213 219); +} + +/* transitions for smooth theme switching */ +*, +*::before, +*::after { + transition: + background-color 0.2s ease, + border-color 0.2s ease, + color 0.2s ease; +} + +/* override transitions for elements that shouldn't animate */ +.no-transition, +.no-transition *, +.no-transition *::before, +.no-transition *::after { + transition: none !important; } diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index ed08fdb..a998180 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -234,6 +234,43 @@ export class API { */ runUpdate: async () => { return await postJSON(this.getPath(`/update`)); + }, + + /** + * Create a backup + * @returns {Promise} + */ + createBackup: async () => { + return await postJSON(this.getPath(`/backup/create`)); + }, + + /** + * List available backups + * @returns {Promise} + */ + listBackups: async () => { + return await getJSON(this.getPath(`/backup/list`)); + }, + + /** + * Download a backup file + * @param {string} filename - name of the backup file + * @returns {Promise} + */ + downloadBackup: async (filename) => { + const response = await fetch( + this.getPath(`/backup/download/${encodeURIComponent(filename)}`), + { + method: 'GET', + credentials: 'same-origin' + } + ); + + if (!response.ok) { + throw new Error(`Failed to download backup: ${response.statusText}`); + } + + return await response.blob(); } }; @@ -689,6 +726,26 @@ export class API { return await postJSON(this.getPath(`/campaign/recipient/${campaignRecipient}/sent`)); }, + /** + * Send message to campaign recipient (works for both email and API senders) + * + * @param {string} campaignRecipientID + * @returns {Promise} + */ + sendMessage: async (campaignRecipientID) => { + return await postJSON(this.getPath(`/campaign/recipient/${campaignRecipientID}/send`)); + }, + + /** + * Send email to campaign recipient (alias for sendMessage for backward compatibility) + * + * @param {string} campaignRecipientID + * @returns {Promise} + */ + sendEmail: async (campaignRecipientID) => { + return await postJSON(this.getPath(`/campaign/recipient/${campaignRecipientID}/send`)); + }, + /** * Get campaign recipient landingpage URL. * diff --git a/frontend/src/lib/components/Alert.svelte b/frontend/src/lib/components/Alert.svelte index 29c1d6c..21b51cc 100644 --- a/frontend/src/lib/components/Alert.svelte +++ b/frontend/src/lib/components/Alert.svelte @@ -243,7 +243,9 @@ {#if visible} -
+