From ac288a29f797cb17a07ad3f054b418817f782f71 Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Tue, 16 Sep 2025 16:42:07 +0200 Subject: [PATCH 01/18] fix missing error Signed-off-by: Ronni Skansing --- frontend/src/routes/domain/+page.svelte | 33 +++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/domain/+page.svelte b/frontend/src/routes/domain/+page.svelte index 6a84332..2beeabe 100644 --- a/frontend/src/routes/domain/+page.svelte +++ b/frontend/src/routes/domain/+page.svelte @@ -166,7 +166,35 @@ const onSubmitPageUpdate = async () => { try { - await onClickUpdate(); + isSubmitting = true; + updateContentError = ''; + // clear site contents if not hosting a website + if (!formValues.hostWebsite) { + 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 + }); + if (!res.success) { + updateContentError = res.error; + return; + } + addToast('Domain updated', 'Success'); + closeAllModals(); + refreshDomains(); + } catch (e) { + addToast('Failed to update domain', 'Error'); + console.error('failed to update domain', e); } finally { isSubmitting = false; } @@ -401,6 +429,7 @@ const closeAllModals = () => { modalError = ''; + updateContentError = ''; formValues.id = null; if (form) { form.reset(); @@ -678,7 +707,7 @@ From d72ea4e9f522f4e2a73e7b390fc7ac71bf3856ac Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Tue, 16 Sep 2025 16:42:27 +0200 Subject: [PATCH 02/18] improve modal error position Signed-off-by: Ronni Skansing --- frontend/src/lib/components/FormError.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/components/FormError.svelte b/frontend/src/lib/components/FormError.svelte index d96b23f..6a78dd6 100644 --- a/frontend/src/lib/components/FormError.svelte +++ b/frontend/src/lib/components/FormError.svelte @@ -4,7 +4,7 @@ {#if message} -
+
Date: Tue, 16 Sep 2025 16:42:40 +0200 Subject: [PATCH 03/18] perform validation on save Signed-off-by: Ronni Skansing --- backend/app/services.go | 2 + backend/service/domain.go | 24 ++++++++ backend/service/email.go | 12 ++++ backend/service/page.go | 13 ++++ backend/service/templateService.go | 99 ++++++++++++++++++++++++++++++ 5 files changed, 150 insertions(+) diff --git a/backend/app/services.go b/backend/app/services.go index fb23e4a..761aab8 100644 --- a/backend/app/services.go +++ b/backend/app/services.go @@ -138,6 +138,7 @@ func NewServices( CampaignRepository: repositories.Campaign, PageRepository: repositories.Page, CampaignTemplateService: campaignTemplate, + TemplateService: templateService, } domain := &service.Domain{ Common: common, @@ -149,6 +150,7 @@ func NewServices( CampaignTemplateService: campaignTemplate, AssetService: asset, FileService: file, + TemplateService: templateService, } email := &service.Email{ Common: common, 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..77bb5b1 100644 --- a/backend/service/email.go +++ b/backend/service/email.go @@ -171,6 +171,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 { @@ -780,6 +787,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..ecde82d 100644 --- a/backend/service/templateService.go +++ b/backend/service/templateService.go @@ -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" ) @@ -71,6 +72,104 @@ func (t *Template) CreateMail( ) } +// ValidatePageTemplate validates that a page template can be parsed and executed without errors +func (t *Template) ValidatePageTemplate(content string) error { + // use the same parsing approach as CreatePhishingPage but without executing + _, 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 { + // use the same parsing approach as email creation but without executing + _, 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 { + // use the same parsing approach as domain content but without executing + _, 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 From 854e0243d02826422c3b522d22d509b4e471e64d Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Wed, 17 Sep 2025 19:52:40 +0200 Subject: [PATCH 04/18] Add reported functionality Signed-off-by: Ronni Skansing --- backend/app/administration.go | 2 + backend/cache/local.go | 1 + backend/controller/campaign.go | 69 +++++ backend/data/events.go | 2 + backend/database/campaignStats.go | 2 + backend/model/campaignResultView.go | 1 + backend/model/recipientCampaignStatsView.go | 1 + backend/repository/campaign.go | 26 ++ backend/repository/recipient.go | 12 + backend/service/campaign.go | 267 +++++++++++++++++- backend/testfiles/reporters.csv | 2 + .../lib/components/CampaignTrendChart.svelte | 39 ++- frontend/src/lib/utils/events.js | 5 + .../src/routes/campaign/[id]/+page.svelte | 196 ++++++++++--- frontend/src/routes/dashboard/+page.svelte | 2 + .../src/routes/recipient/[id]/+page.svelte | 29 +- frontend/tailwind.config.js | 22 +- 17 files changed, 619 insertions(+), 59 deletions(-) create mode 100644 backend/testfiles/reporters.csv diff --git a/backend/app/administration.go b/backend/app/administration.go index 81c039a..5a37b1a 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -132,6 +132,7 @@ 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" @@ -364,6 +365,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 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/campaign.go b/backend/controller/campaign.go index 1e66399..ee2c6cc 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" @@ -935,3 +937,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/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/campaign.go b/backend/service/campaign.go index 52da103..c2398c0 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -766,7 +766,6 @@ func (c *Campaign) GetStats( if err != nil { return nil, errs.Wrap(err) } - // no audit on read return &model.CampaignsStatView{ Active: active, Upcoming: upcoming, @@ -3051,11 +3050,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 +3117,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 +3187,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/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/lib/components/CampaignTrendChart.svelte b/frontend/src/lib/components/CampaignTrendChart.svelte index be3bae4..2c22f40 100644 --- a/frontend/src/lib/components/CampaignTrendChart.svelte +++ b/frontend/src/lib/components/CampaignTrendChart.svelte @@ -85,8 +85,10 @@ openRate: true, clickRate: true, submissionRate: true, + reportRate: true, 'mavg-clickRate': true, - 'mavg-submissionRate': true + 'mavg-submissionRate': true, + 'mavg-reportRate': true }; // Responsive margins based on container width @@ -138,7 +140,8 @@ const metrics = [ { key: 'openRate', label: 'Read Rate', color: '#4cb5b5', suffix: '%' }, { key: 'clickRate', label: 'Click Rate', color: '#f96dcf', suffix: '%' }, - { key: 'submissionRate', label: 'Submission Rate', color: '#f42e41', suffix: '%' } + { key: 'submissionRate', label: 'Submission Rate', color: '#f42e41', suffix: '%' }, + { key: 'reportRate', label: 'Report Rate', color: '#1e40af', suffix: '%' } ]; // Toggle metric visibility @@ -159,7 +162,8 @@ n, openRate: avg(slice, 'openRate'), clickRate: avg(slice, 'clickRate'), - submissionRate: avg(slice, 'submissionRate') + submissionRate: avg(slice, 'submissionRate'), + reportRate: avg(slice, 'reportRate') }; })(); @@ -205,6 +209,7 @@ clickRate: Math.round((stat.clickRate || 0) * (stat.clickRate > 1 ? 1 : 100) * 10) / 10, submissionRate: Math.round((stat.submissionRate || 0) * (stat.submissionRate > 1 ? 1 : 100) * 10) / 10, + reportRate: Math.round((stat.reportRate || 0) * (stat.reportRate > 1 ? 1 : 100) * 10) / 10, totalRecipients: stat.totalRecipients })); } @@ -247,8 +252,8 @@ createLine(svg, metric); } }); - // Only draw moving average for clickRate and submissionRate, using user-selected N - ['clickRate', 'submissionRate'].forEach((metricKey) => { + // Only draw moving average for clickRate, submissionRate, and reportRate, using user-selected N + ['clickRate', 'submissionRate', 'reportRate'].forEach((metricKey) => { const metric = metrics.find((m) => m.key === metricKey); if (metric && visibleMetrics[`mavg-${metricKey}`]) { createMovingAverageLine(svg, metric, movingAvgN); @@ -309,6 +314,8 @@ avgColor = '#93c5fd'; // light blue } else if (metric.key === 'submissionRate') { avgColor = '#ff6a91'; // lighter red, closer to #f42e41 + } else if (metric.key === 'reportRate') { + avgColor = '#60a5fa'; // lighter blue for report rate } path.setAttribute('stroke', avgColor); path.setAttribute('stroke-width', '1.2'); @@ -545,18 +552,28 @@ strokeDasharray: null, opacity: 1 }); - if (metric.key === 'clickRate' || metric.key === 'submissionRate') { + if ( + metric.key === 'clickRate' || + metric.key === 'submissionRate' || + metric.key === 'reportRate' + ) { // Use a lighter version of the main color for moving averages let avgColor = metric.color; + let avgLabel = ''; if (metric.key === 'clickRate') { avgColor = '#eea5fa'; // before-page-visited, lighter pink + avgLabel = 'Click MA'; } else if (metric.key === 'submissionRate') { avgColor = '#ff6a91'; // lighter red, closer to #f42e41 + avgLabel = 'Submit MA'; + } else if (metric.key === 'reportRate') { + avgColor = '#60a5fa'; // lighter blue for report rate + avgLabel = 'Report MA'; } legendItems.push({ type: 'mavg', key: metric.key, - label: metric.key === 'clickRate' ? 'Click MA' : 'Submit MA', + label: avgLabel, color: avgColor, class: `legend-line legend-mavg legend-mavg-${metric.key}`, labelClass: `legend-label legend-mavg legend-mavg-${metric.key}`, @@ -863,7 +880,7 @@

-
+
{chartData[0].openRate}%
Open Rate
@@ -876,6 +893,10 @@
{chartData[0].submissionRate}%
Submission Rate
+
+
{chartData[0].reportRate}%
+
Report Rate
+
{:else if hasAttemptedLoad && !isLoading && !debouncedIsLoading && chartData.length >= 2} @@ -926,7 +947,7 @@
{#if chartData.length > 0} -
+
{#each metrics as metric}
diff --git a/frontend/src/lib/utils/events.js b/frontend/src/lib/utils/events.js index 0d553e3..24235c7 100644 --- a/frontend/src/lib/utils/events.js +++ b/frontend/src/lib/utils/events.js @@ -26,6 +26,11 @@ const eventNameMap = { priority: 90, color: 'bg-submitted-data' }, + campaign_recipient_reported: { + name: 'Reported', + priority: 95, + color: 'bg-reported' + }, // campaign events campaign_scheduled: { name: 'Scheduled', priority: 10 }, campaign_active: { name: 'Active', priority: 20 }, diff --git a/frontend/src/routes/campaign/[id]/+page.svelte b/frontend/src/routes/campaign/[id]/+page.svelte index 4f0c265..aae61c1 100644 --- a/frontend/src/routes/campaign/[id]/+page.svelte +++ b/frontend/src/routes/campaign/[id]/+page.svelte @@ -83,7 +83,8 @@ emailsSent: 0, trackingPixelLoaded: 0, websiteLoaded: 0, - submittedData: 0 + submittedData: 0, + reported: 0 }; // @ts-ignore const recipientTableUrlParams = newTableURLParams({ @@ -340,6 +341,7 @@ result.trackingPixelLoaded = res.data.trackingPixelLoaded; result.websiteLoaded = res.data.clickedLink; result.submittedData = res.data.submittedData; + result.reported = res.data.reported; } catch (e) { addToast('Failed to load campaign result stats', 'Error'); console.error('failed to load campaign result stats', e); @@ -685,6 +687,54 @@ const onClickUpdateCampaign = () => { goto(`/campaign?update=${$page.params.id}`); }; + + const onUploadReportedCSV = async (event) => { + const file = event.target.files?.[0]; + if (!file) return; + + // validate file type + if (!file.name.toLowerCase().endsWith('.csv')) { + addToast('Please select a CSV file', 'Error'); + event.target.value = ''; + return; + } + + const formData = new FormData(); + formData.append('file', file); + + try { + showIsLoading(); + const response = await fetch(`/api/v1/campaign/${$page.params.id}/upload/reported`, { + method: 'POST', + body: formData, + credentials: 'include' + }); + + const result = await response.json(); + + if (response.ok && result.success) { + addToast( + `Successfully processed ${result.data.processed} reported entries${result.data.skipped > 0 ? `, skipped ${result.data.skipped} invalid entries` : ''}`, + 'Success' + ); + // refresh the stats, events, and recipients table + await setResults(); + await refreshCampaignRecipients(); + await getEvents(); + } else { + // handle validation errors + const errorMessage = result.error || `HTTP ${response.status}`; + addToast(`Upload failed: ${errorMessage}`, 'Error'); + } + } catch (error) { + console.error('Upload error:', error); + addToast('Network error: Failed to upload CSV', 'Error'); + } finally { + hideIsLoading(); + // clear the file input + event.target.value = ''; + } + }; @@ -724,7 +774,9 @@ />
-
+
+ + + + + +
Event Timeline @@ -1092,45 +1189,74 @@
-
+

Actions

-
- {#if !campaignUpdateDisabledAndTitle(campaign).disabled} +
+ +
+ {#if !campaignUpdateDisabledAndTitle(campaign).disabled} + + {/if} - {/if} - - - - + +
+ + +
+ + +
+ + +
+
+ + +

+ CSV format: "Reported by" (email), "Date reported(UTC+02:00)" +

+
+
diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte index 3cf7742..ba185ed 100644 --- a/frontend/src/routes/dashboard/+page.svelte +++ b/frontend/src/routes/dashboard/+page.svelte @@ -442,6 +442,7 @@ { column: 'Open Rate', size: 'small' }, { column: 'Click Rate', size: 'small' }, { column: 'Submission Rate', size: 'small' }, + { column: 'Report Rate', size: 'small' }, { column: 'Closed', size: 'small' } ]} hasData={!!campaignStats.length} @@ -460,6 +461,7 @@ + {/each} diff --git a/frontend/src/routes/recipient/[id]/+page.svelte b/frontend/src/routes/recipient/[id]/+page.svelte index 478de9c..78c5096 100644 --- a/frontend/src/routes/recipient/[id]/+page.svelte +++ b/frontend/src/routes/recipient/[id]/+page.svelte @@ -37,7 +37,8 @@ campaignsParticiated: 0, campaignsTrackingPixelLoaded: 0, campaignsPhishingPageLoaded: 0, - campaignsDataSubmitted: 0 + campaignsDataSubmitted: 0, + campaignsReported: 0 }; let isGroupsLoading = false; let isEventsLoading = false; @@ -155,7 +156,7 @@ {/if} Export events
-
+
+ + + + + + + Date: Wed, 17 Sep 2025 22:16:04 +0200 Subject: [PATCH 05/18] recipient manual send action Signed-off-by: Ronni Skansing --- backend/app/administration.go | 8 +- backend/controller/campaign.go | 21 + backend/service/campaign.go | 441 ++++++++++++++++++ frontend/src/lib/api/api.js | 10 + .../src/routes/campaign/[id]/+page.svelte | 90 ++++ 5 files changed, 567 insertions(+), 3 deletions(-) diff --git a/backend/app/administration.go b/backend/app/administration.go index 5a37b1a..87b8444 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -134,9 +134,10 @@ const ( 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" @@ -373,6 +374,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). diff --git a/backend/controller/campaign.go b/backend/controller/campaign.go index ee2c6cc..f5a379a 100644 --- a/backend/controller/campaign.go +++ b/backend/controller/campaign.go @@ -854,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 email + 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 diff --git a/backend/service/campaign.go b/backend/service/campaign.go index c2398c0..935df1e 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -2961,6 +2961,447 @@ 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, + ) + + err = m.SetBodyHTMLTemplate(mailTmpl, t) + if err != nil { + c.Logger.Errorw("failed to set body html template", "error", err) + return errs.Wrap(err) + } + + // 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, diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index ed08fdb..a196582 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -689,6 +689,16 @@ export class API { return await postJSON(this.getPath(`/campaign/recipient/${campaignRecipient}/sent`)); }, + /** + * Send email to campaign recipient + * + * @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/routes/campaign/[id]/+page.svelte b/frontend/src/routes/campaign/[id]/+page.svelte index aae61c1..323a92f 100644 --- a/frontend/src/routes/campaign/[id]/+page.svelte +++ b/frontend/src/routes/campaign/[id]/+page.svelte @@ -121,6 +121,8 @@ let isRecipientTableLoading = false; let isCloseModalVisible = false; let isAnonymizeModalVisible = false; + let isSendEmailModalVisible = false; + let sendEmailRecipient = null; let lastPoll3399Nano = ''; // hooks @@ -489,6 +491,50 @@ } }; + /** @param {string} campaignRecipientID @param {Object} recipient */ + const showSendEmailModal = (campaignRecipientID, recipient) => { + sendEmailRecipient = { + id: campaignRecipientID, + name: `${recipient.firstName || ''} ${recipient.lastName || ''}`.trim(), + email: recipient.email + }; + isSendEmailModalVisible = true; + }; + + const closeSendEmailModal = () => { + isSendEmailModalVisible = false; + sendEmailRecipient = null; + }; + + const onConfirmSendEmail = async () => { + try { + showIsLoading(); + // Check if this is a resend before sending + const isResend = campaignRecipients.find((r) => r.id === sendEmailRecipient.id)?.sentAt; + const res = await api.campaign.sendEmail(sendEmailRecipient.id); + if (!res.success) { + throw res.error; + } + const campaignType = isSelfManaged ? 'self-managed' : 'scheduled'; + const message = isResend ? `Email sent again successfully` : `Email sent successfully`; + addToast(message, 'Success'); + await setCampaign(); + await getEvents(); + await refreshCampaignRecipients(); + closeSendEmailModal(); + } catch (e) { + addToast('Failed to send email', 'Error'); + console.error('failed to send email', e); + } finally { + hideIsLoading(); + } + }; + + // reactive statement to clean up send email modal state when it closes + $: if (!isSendEmailModalVisible && sendEmailRecipient) { + sendEmailRecipient = null; + } + const showCloseCampaignModal = () => { isCloseModalVisible = true; }; @@ -1412,6 +1458,16 @@ disabled={!recp.recipient} on:click={() => openEventsModal(recp.recipientID)} /> + showSendEmailModal(recp.id, recp.recipient)} + disabled={!!campaign.closedAt || recp.cancelledAt} + /> {#if !campaign.sendStartAt}
+ + +
+ {#if sendEmailRecipient} + {@const recipient = campaignRecipients.find((r) => r.id === sendEmailRecipient.id)} + {#if recipient} +

+ {recipient.sentAt + ? 'Are you sure you want to send the campaign email again to:' + : 'Are you sure you want to send the campaign email to:'} +

+
+

{sendEmailRecipient.name}

+

{sendEmailRecipient.email}

+

+ Campaign type: {isSelfManaged ? 'Self-managed' : 'Scheduled'} +

+ {#if recipient.sentAt} +

+ ⚠️ Previously sent on {new Date(recipient.sentAt).toLocaleString()} +

+ {/if} +
+

+ This action will immediately send the email and cannot be undone. +

+ {/if} + {/if} +
+
From df85aaa4cad2c768c8d79ec996e4fb406a3cbf6a Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Wed, 17 Sep 2025 23:15:47 +0200 Subject: [PATCH 06/18] move recent campaigns to bottom of dashboard Signed-off-by: Ronni Skansing --- frontend/src/routes/dashboard/+page.svelte | 73 +++++++++++----------- 1 file changed, 37 insertions(+), 36 deletions(-) diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte index ba185ed..b3ac2f4 100644 --- a/frontend/src/routes/dashboard/+page.svelte +++ b/frontend/src/routes/dashboard/+page.svelte @@ -431,42 +431,6 @@ />
- Recent Campaigns -
- - {#each campaignStats as stat} - - - - {stat.campaignName} - - - - - - - - - - - {/each} -
-
Active campaigns
+ + Recent Campaigns +
+
+ {#each campaignStats as stat} + + + + {stat.campaignName} + + + + + + + + + + + {/each} +
+
From cd3baf028b25c4e584f5c59442f7eb2a7fa4e55f Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Wed, 17 Sep 2025 23:43:59 +0200 Subject: [PATCH 07/18] fix dashboard scroll to top issue Signed-off-by: Ronni Skansing --- .../lib/components/CampaignTrendChart.svelte | 17 ++-- frontend/src/routes/dashboard/+page.svelte | 88 ++++++++++++++++++- 2 files changed, 93 insertions(+), 12 deletions(-) diff --git a/frontend/src/lib/components/CampaignTrendChart.svelte b/frontend/src/lib/components/CampaignTrendChart.svelte index 2c22f40..d812857 100644 --- a/frontend/src/lib/components/CampaignTrendChart.svelte +++ b/frontend/src/lib/components/CampaignTrendChart.svelte @@ -58,6 +58,7 @@ } let chartContainer; + let sizingContainer; let width = 300; let height = 200; // Balanced height to prevent overflow let containerReady = false; @@ -777,9 +778,9 @@ } onMount(async () => { - if (chartContainer) { - await tick(); // Wait for DOM/layout - const containerWidth = chartContainer.clientWidth || 0; + await tick(); // Wait for DOM/layout + if (sizingContainer) { + const containerWidth = sizingContainer.parentElement?.clientWidth || 0; width = Math.min(Math.max(containerWidth, 300), containerWidth); // Minimum 300px but never exceed container if (width > 0) containerReady = true; resizeObserver = new ResizeObserver((entries) => { @@ -791,13 +792,13 @@ } } }); - resizeObserver.observe(chartContainer); + resizeObserver.observe(sizingContainer.parentElement || sizingContainer); } }); onDestroy(() => { - if (resizeObserver && chartContainer) { - resizeObserver.unobserve(chartContainer); + if (resizeObserver && sizingContainer) { + resizeObserver.unobserve(sizingContainer.parentElement || sizingContainer); } if (loadingTimeout) { clearTimeout(loadingTimeout); @@ -814,9 +815,9 @@
- + diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte index b3ac2f4..e09f9e3 100644 --- a/frontend/src/routes/dashboard/+page.svelte +++ b/frontend/src/routes/dashboard/+page.svelte @@ -88,9 +88,9 @@ contextCompanyName = context.companyName; } refresh(); - activeTableURLParams.onChange(refreshActiveCampaigns); - scheduledTableURLParams.onChange(refreshScheduledCampaigns); - completedTableURLParams.onChange(refreshFinishedCampaigns); + activeTableURLParams.onChange(() => refreshActiveCampaigns(true)); + scheduledTableURLParams.onChange(() => refreshScheduledCampaigns(true)); + completedTableURLParams.onChange(() => refreshFinishedCampaigns(true)); return () => { activeTableURLParams.unsubscribe(); @@ -305,7 +305,87 @@ { - await refresh(false); + // refresh all data + let res = await api.campaign.getStats(contextCompanyID, { + includeTest: includeTestCampaigns + }); + if (!res.success) { + throw res.error; + } + await refreshRepeatOffenders(); + + active = res.data.active; + scheduled = res.data.upcoming; + finished = res.data.finished; + + // refresh table data directly like campaign page does + const activeOptions = { + page: activeTableURLParams.currentPage, + perPage: activeTableURLParams.perPage, + sortBy: activeTableURLParams.sortBy, + sortOrder: activeTableURLParams.sortOrder, + search: activeTableURLParams.search, + includeTest: includeTestCampaigns + }; + const activeRes = await api.campaign.getAllActive(activeOptions, contextCompanyID); + if (activeRes.success) { + activeCampaigns = []; + await tick(); + activeCampaigns = activeRes.data.rows; + } + + const scheduledOptions = { + page: scheduledTableURLParams.currentPage, + perPage: scheduledTableURLParams.perPage, + sortBy: scheduledTableURLParams.sortBy, + sortOrder: scheduledTableURLParams.sortOrder, + search: scheduledTableURLParams.search, + includeTest: includeTestCampaigns + }; + const scheduledRes = await api.campaign.getAllUpcoming( + scheduledOptions, + contextCompanyID + ); + if (scheduledRes.success) { + scheduledCampaigns = []; + await tick(); + scheduledCampaigns = scheduledRes.data.rows; + } + + const completedOptions = { + page: completedTableURLParams.currentPage, + perPage: completedTableURLParams.perPage, + sortBy: completedTableURLParams.sortBy, + sortOrder: completedTableURLParams.sortOrder, + search: completedTableURLParams.search, + includeTest: includeTestCampaigns + }; + const completedRes = await api.campaign.getAllFinished( + completedOptions, + contextCompanyID + ); + if (completedRes.success) { + completedCampaigns = []; + await tick(); + completedCampaigns = completedRes.data.rows; + } + + const statsOptions = { + page: 1, + perPage: 10, + sortBy: 'campaign_closed_at', + sortOrder: 'desc', + search: '', + includeTest: includeTestCampaigns + }; + const statsRes = await api.campaign.getAllCampaignStats(statsOptions, contextCompanyID); + if (statsRes.success) { + campaignStats = []; + await tick(); + campaignStats = statsRes.data.rows || []; + } + + await refreshCalendarCampaings(); }} />
From 0f7a969b06e846679a4eed0c6465eb7638b9916f Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Thu, 18 Sep 2025 10:35:49 +0200 Subject: [PATCH 08/18] added link to release information on update modal and page Signed-off-by: Ronni Skansing --- .../src/routes/settings/update/+page.svelte | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/frontend/src/routes/settings/update/+page.svelte b/frontend/src/routes/settings/update/+page.svelte index d476a91..2333be1 100644 --- a/frontend/src/routes/settings/update/+page.svelte +++ b/frontend/src/routes/settings/update/+page.svelte @@ -86,6 +86,15 @@ Update Available!

Version {newVersion} is now available

+

+ + View release notes → + +

{#if !isUpdateLocal}

This instance is was not setup using the systemd install and must be @@ -129,6 +138,15 @@

An update notification is visible when a update is ready.

+

+ + View previous release information + +

{/if}
From f2ea4e8570ad3c7fc1b556f0c3a36fac584aade9 Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Thu, 18 Sep 2025 14:00:37 +0200 Subject: [PATCH 09/18] bump golang version Signed-off-by: Ronni Skansing --- .github/workflows/release.yml | 2 +- .github/workflows/test-build.yml | 2 +- backend/Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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/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 From 1644389f7f427cc16e1bbaca77fab1153b3e3f98 Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Thu, 18 Sep 2025 14:04:56 +0200 Subject: [PATCH 10/18] bump go mod Signed-off-by: Ronni Skansing --- backend/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 4d8aac54a11932cc8494282a1263462f2c2ced1b Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Thu, 18 Sep 2025 14:05:23 +0200 Subject: [PATCH 11/18] add manual backup Signed-off-by: Ronni Skansing --- backend/app/administration.go | 8 + backend/app/controllers.go | 6 + backend/app/services.go | 9 + backend/controller/backup.go | 74 +++ backend/main.go | 1 + backend/service/backup.go | 582 ++++++++++++++++++ frontend/src/lib/api/api.js | 37 ++ frontend/src/lib/components/Button.svelte | 4 +- frontend/src/routes/settings/+page.svelte | 169 +++++ .../src/routes/settings/update/+page.svelte | 8 +- 10 files changed, 893 insertions(+), 5 deletions(-) create mode 100644 backend/controller/backup.go create mode 100644 backend/service/backup.go diff --git a/backend/app/administration.go b/backend/app/administration.go index 87b8444..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" @@ -419,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/services.go b/backend/app/services.go index 761aab8..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, @@ -212,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, @@ -252,5 +260,6 @@ func NewServices( SSO: ssoService, Update: updateService, Import: importService, + Backup: backupService, } } 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/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/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/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index a196582..8f232cc 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(); } }; diff --git a/frontend/src/lib/components/Button.svelte b/frontend/src/lib/components/Button.svelte index 086cab8..bc73341 100644 --- a/frontend/src/lib/components/Button.svelte +++ b/frontend/src/lib/components/Button.svelte @@ -1,10 +1,11 @@
+ + +
+

Backup

+
+
+

+ Create a backup of database, assets, attachments and certificates. +

+ + {#if availableBackups.length > 0} +
+

Available:

+
+ {#each availableBackups as backup} +
+
+ + {new Date(backup.createdAt).toLocaleString()} + + + {(backup.size / 1024 / 1024).toFixed(1)} MB + +
+ +
+ {/each} +
+
+ {:else if !isLoadingBackups} +
+ No backups available yet. +
+ {/if} +
+
+ +
+
+
@@ -830,4 +949,54 @@ bind:isVisible={isSSODeleteAlertVisible} /> {/if} + + {#if isBackupModalVisible} + + + + +
+

This will create a backup file that can be downloaded from the settings page.

+

+ Note: This is not a substitute for having proper automated and tested + backup and recovery plans at the operating system level. +

+
+

What will be backed up:

+
    +
  • • SQLite database (including WAL files)
  • +
  • • Asset files
  • +
  • • Attachment files
  • +
  • • Certificate files
  • +
+
+ +
+

Important:

+
    +
  • • Large databases may take significant time to backup
  • +
  • • Operations may be affected during the backup process
  • +
  • • Ensure you have sufficient disk space
  • +
  • + • Only the 3 most recent backups are kept (older ones are automatically deleted) +
  • +
  • • The backup does not include config.json or the application binary
  • +
+
+
+
+
+ +
+
+ {/if} diff --git a/frontend/src/routes/settings/update/+page.svelte b/frontend/src/routes/settings/update/+page.svelte index 2333be1..ecee318 100644 --- a/frontend/src/routes/settings/update/+page.svelte +++ b/frontend/src/routes/settings/update/+page.svelte @@ -78,13 +78,10 @@
- {#if isUpdateAvailable} + {#if isUpdateAvailable || true}
From 7d2fb2b888cf7f9d981ddf54bf2171f7448f4bb9 Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Thu, 18 Sep 2025 14:49:53 +0200 Subject: [PATCH 12/18] improve send again texts Signed-off-by: Ronni Skansing --- backend/controller/campaign.go | 2 +- frontend/src/lib/api/api.js | 12 ++- .../src/routes/campaign/[id]/+page.svelte | 77 ++++++++++--------- 3 files changed, 54 insertions(+), 37 deletions(-) diff --git a/backend/controller/campaign.go b/backend/controller/campaign.go index f5a379a..3e9fb8f 100644 --- a/backend/controller/campaign.go +++ b/backend/controller/campaign.go @@ -866,7 +866,7 @@ func (c *Campaign) SendEmailByCampaignRecipientID(g *gin.Context) { if !ok { return } - // send email + // 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 { diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index 8f232cc..a998180 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -727,7 +727,17 @@ export class API { }, /** - * Send email to campaign recipient + * 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} diff --git a/frontend/src/routes/campaign/[id]/+page.svelte b/frontend/src/routes/campaign/[id]/+page.svelte index 323a92f..fc0ea29 100644 --- a/frontend/src/routes/campaign/[id]/+page.svelte +++ b/frontend/src/routes/campaign/[id]/+page.svelte @@ -121,8 +121,8 @@ let isRecipientTableLoading = false; let isCloseModalVisible = false; let isAnonymizeModalVisible = false; - let isSendEmailModalVisible = false; - let sendEmailRecipient = null; + let isSendMessageModalVisible = false; + let sendMessageRecipient = null; let lastPoll3399Nano = ''; // hooks @@ -492,47 +492,54 @@ }; /** @param {string} campaignRecipientID @param {Object} recipient */ - const showSendEmailModal = (campaignRecipientID, recipient) => { - sendEmailRecipient = { + const showSendMessageModal = (campaignRecipientID, recipient) => { + sendMessageRecipient = { id: campaignRecipientID, name: `${recipient.firstName || ''} ${recipient.lastName || ''}`.trim(), email: recipient.email }; - isSendEmailModalVisible = true; + isSendMessageModalVisible = true; }; - const closeSendEmailModal = () => { - isSendEmailModalVisible = false; - sendEmailRecipient = null; + const closeSendMessageModal = () => { + isSendMessageModalVisible = false; + sendMessageRecipient = null; }; - const onConfirmSendEmail = async () => { + // helper function to get appropriate messaging based on sender type + const getMessageType = () => { + return campaign.template?.email ? 'email' : 'message'; + }; + + const onConfirmSendMessage = async () => { try { showIsLoading(); // Check if this is a resend before sending - const isResend = campaignRecipients.find((r) => r.id === sendEmailRecipient.id)?.sentAt; - const res = await api.campaign.sendEmail(sendEmailRecipient.id); + const isResend = campaignRecipients.find((r) => r.id === sendMessageRecipient.id)?.sentAt; + const res = await api.campaign.sendMessage(sendMessageRecipient.id); if (!res.success) { throw res.error; } - const campaignType = isSelfManaged ? 'self-managed' : 'scheduled'; - const message = isResend ? `Email sent again successfully` : `Email sent successfully`; + const messageType = getMessageType(); + const message = isResend + ? `${messageType.charAt(0).toUpperCase() + messageType.slice(1)} sent again successfully` + : `${messageType.charAt(0).toUpperCase() + messageType.slice(1)} sent successfully`; addToast(message, 'Success'); await setCampaign(); await getEvents(); await refreshCampaignRecipients(); - closeSendEmailModal(); + closeSendMessageModal(); } catch (e) { - addToast('Failed to send email', 'Error'); - console.error('failed to send email', e); + addToast(`Failed to send ${getMessageType()}`, 'Error'); + console.error(`failed to send ${getMessageType()}`, e); } finally { hideIsLoading(); } }; - // reactive statement to clean up send email modal state when it closes - $: if (!isSendEmailModalVisible && sendEmailRecipient) { - sendEmailRecipient = null; + // reactive statement to clean up send message modal state when it closes + $: if (!isSendMessageModalVisible && sendMessageRecipient) { + sendMessageRecipient = null; } const showCloseCampaignModal = () => { @@ -1459,13 +1466,15 @@ on:click={() => openEventsModal(recp.recipientID)} /> showSendEmailModal(recp.id, recp.recipient)} + : recp.sentAt + ? `Send ${getMessageType()} again (last sent: ${new Date(recp.sentAt).toLocaleDateString()})` + : `Send ${getMessageType()} to recipient`} + on:click={() => showSendMessageModal(recp.id, recp.recipient)} disabled={!!campaign.closedAt || recp.cancelledAt} /> {#if !campaign.sendStartAt} @@ -1756,24 +1765,24 @@
- {#if sendEmailRecipient} - {@const recipient = campaignRecipients.find((r) => r.id === sendEmailRecipient.id)} + {#if sendMessageRecipient} + {@const recipient = campaignRecipients.find((r) => r.id === sendMessageRecipient.id)} {#if recipient}

{recipient.sentAt - ? 'Are you sure you want to send the campaign email again to:' - : 'Are you sure you want to send the campaign email to:'} + ? `Are you sure you want to send the campaign ${getMessageType()} again to:` + : `Are you sure you want to send the campaign ${getMessageType()} to:`}

-

{sendEmailRecipient.name}

-

{sendEmailRecipient.email}

+

{sendMessageRecipient.name}

+

{sendMessageRecipient.email}

- Campaign type: {isSelfManaged ? 'Self-managed' : 'Scheduled'} + Sender type: {campaign.template?.email ? 'Email (SMTP)' : 'API Sender'}

{#if recipient.sentAt}

@@ -1781,9 +1790,7 @@

{/if}
-

- This action will immediately send the email and cannot be undone. -

+

This action will immediately send the message.

{/if} {/if}
From 2e09c0df5c3921f9b8e6e0455a57989c2621d986 Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Thu, 18 Sep 2025 22:09:47 +0200 Subject: [PATCH 13/18] fix bad title on settings page Signed-off-by: Ronni Skansing --- frontend/src/routes/settings/+page.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index d0bf358..dd69009 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -433,9 +433,9 @@ }; - +
- Profile + Settings {#if isInitiallyLoaded}
From 1759e0c985d163da645845345dbd4872ff20d9a3 Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Thu, 18 Sep 2025 22:22:54 +0200 Subject: [PATCH 14/18] remove test bool Signed-off-by: Ronni Skansing --- frontend/src/routes/settings/update/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/routes/settings/update/+page.svelte b/frontend/src/routes/settings/update/+page.svelte index ecee318..0a50ea9 100644 --- a/frontend/src/routes/settings/update/+page.svelte +++ b/frontend/src/routes/settings/update/+page.svelte @@ -78,7 +78,7 @@
- {#if isUpdateAvailable || true} + {#if isUpdateAvailable}
From 1aab0e499c4a3df4c20bc5adf3eb712e3a9331f8 Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Fri, 19 Sep 2025 00:29:31 +0200 Subject: [PATCH 15/18] change to text/template --- backend/app/server.go | 12 +++-- backend/install/installer.go | 2 +- backend/service/apiSender.go | 7 ++- backend/service/campaign.go | 15 ++++-- backend/service/email.go | 11 ++-- backend/service/templateService.go | 83 ++++++++++++++---------------- 6 files changed, 66 insertions(+), 64 deletions(-) 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/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/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/campaign.go b/backend/service/campaign.go index 935df1e..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" @@ -1951,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 { @@ -3281,11 +3284,13 @@ func (c *Campaign) sendSingleEmailSMTP( 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()) // handle attachments attachments := email.Attachments diff --git a/backend/service/email.go b/backend/service/email.go index 77bb5b1..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" @@ -597,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 { diff --git a/backend/service/templateService.go b/backend/service/templateService.go index ecde82d..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" @@ -58,15 +58,13 @@ 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, ) @@ -74,7 +72,6 @@ func (t *Template) CreateMail( // ValidatePageTemplate validates that a page template can be parsed and executed without errors func (t *Template) ValidatePageTemplate(content string) error { - // use the same parsing approach as CreatePhishingPage but without executing _, err := template.New("validation"). Funcs(TemplateFuncs()). Parse(content) @@ -94,7 +91,6 @@ func (t *Template) ValidatePageTemplate(content string) error { // ValidateEmailTemplate validates that an email template can be parsed and executed without errors func (t *Template) ValidateEmailTemplate(content string) error { - // use the same parsing approach as email creation but without executing _, err := template.New("validation"). Funcs(TemplateFuncs()). Parse(content) @@ -139,7 +135,6 @@ func (t *Template) ValidateEmailTemplate(content string) error { // ValidateDomainTemplate validates that a domain template can be parsed and executed without errors func (t *Template) ValidateDomainTemplate(content string) error { - // use the same parsing approach as domain content but without executing _, err := template.New("validation"). Funcs(TemplateFuncs()). Parse(content) @@ -222,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() @@ -293,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 { @@ -386,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 @@ -505,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") } @@ -564,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" From 77ddce72f37f90fa62e7ab1676c4e938df0161d8 Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Fri, 19 Sep 2025 00:51:45 +0200 Subject: [PATCH 16/18] fix copy campaign wrong text on create Signed-off-by: Ronni Skansing --- frontend/src/routes/campaign/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/routes/campaign/+page.svelte b/frontend/src/routes/campaign/+page.svelte index 99912d3..98d8636 100644 --- a/frontend/src/routes/campaign/+page.svelte +++ b/frontend/src/routes/campaign/+page.svelte @@ -1729,7 +1729,7 @@ class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" disabled={isSubmitting} > - {modalMode === 'create' ? 'Create' : 'Update'} + {modalMode === 'create' || modalMode === 'copy' ? 'Create' : 'Update'} {#if !isSubmitting} Date: Fri, 19 Sep 2025 09:30:47 +0200 Subject: [PATCH 17/18] Dark mode and various smaller UI improvements --- frontend/src/app.css | 287 +++++++++++++++++- frontend/src/lib/components/Alert.svelte | 17 +- .../src/lib/components/AutoRefresh.svelte | 25 +- frontend/src/lib/components/BigButton.svelte | 3 +- frontend/src/lib/components/Button.svelte | 4 +- frontend/src/lib/components/CTAbutton.svelte | 4 +- .../lib/components/CampaignCalendar.svelte | 49 +-- .../lib/components/CampaignTrendChart.svelte | 186 +++++++++--- .../src/lib/components/CheckboxField.svelte | 16 +- .../src/lib/components/ConfirmPrompt.svelte | 27 +- frontend/src/lib/components/DateField.svelte | 14 +- .../src/lib/components/DateTimeField.svelte | 11 +- frontend/src/lib/components/Datetime.svelte | 2 +- .../src/lib/components/DeveloperPanel.svelte | 57 +++- .../src/lib/components/EventTimeline.svelte | 86 +++++- frontend/src/lib/components/FileField.svelte | 8 +- frontend/src/lib/components/Form.svelte | 2 +- frontend/src/lib/components/FormButton.svelte | 2 +- frontend/src/lib/components/FormColumn.svelte | 2 +- .../src/lib/components/FormColumns.svelte | 2 +- frontend/src/lib/components/FormError.svelte | 4 +- frontend/src/lib/components/FormFlex.svelte | 2 +- frontend/src/lib/components/FormFooter.svelte | 4 +- frontend/src/lib/components/FormGrid.svelte | 2 +- frontend/src/lib/components/GhostText.svelte | 8 +- .../src/lib/components/HeaderFirst.svelte | 8 +- frontend/src/lib/components/Headline.svelte | 4 +- frontend/src/lib/components/Hello.svelte | 4 +- frontend/src/lib/components/Input.svelte | 6 +- frontend/src/lib/components/Loader.svelte | 7 +- frontend/src/lib/components/Modal.svelte | 8 +- frontend/src/lib/components/Pagination.svelte | 10 +- .../src/lib/components/PasswordField.svelte | 26 +- .../src/lib/components/RelativeTime.svelte | 1 + frontend/src/lib/components/RootLoader.svelte | 4 +- frontend/src/lib/components/Search.svelte | 2 +- frontend/src/lib/components/Select.svelte | 8 +- .../src/lib/components/SelectSquare.svelte | 20 +- frontend/src/lib/components/StatsCard.svelte | 22 +- .../src/lib/components/SubHeadline.svelte | 4 +- frontend/src/lib/components/TestLabel.svelte | 5 +- frontend/src/lib/components/TextField.svelte | 10 +- .../components/TextFieldMultiSelect.svelte | 24 +- .../components/TextFieldSearchSelect.svelte | 10 +- .../src/lib/components/TextFieldSelect.svelte | 22 +- .../src/lib/components/TextareaField.svelte | 10 +- .../src/lib/components/ThemeToggle.svelte | 52 ++++ frontend/src/lib/components/ToIcon.svelte | 2 +- frontend/src/lib/components/Toast.svelte | 8 +- frontend/src/lib/components/ToolTip.svelte | 4 +- .../src/lib/components/editor/Editor.svelte | 43 ++- .../components/editor/SimpleCodeEditor.svelte | 60 +++- .../lib/components/header/DesktopMenu.svelte | 32 +- .../src/lib/components/header/Header.svelte | 49 ++- .../src/lib/components/header/Logo.svelte | 17 +- .../src/lib/components/header/MenuLink.svelte | 11 +- .../lib/components/header/MobileMenu.svelte | 156 +++++++--- .../lib/components/header/ProfileMenu.svelte | 22 +- .../modal/ChangeCompanyModal.svelte | 20 +- .../lib/components/modal/DeleteAlert.svelte | 12 +- .../src/lib/components/table/CopyCell.svelte | 2 +- .../components/table/EmptyTableResult.svelte | 10 +- .../src/lib/components/table/EventName.svelte | 4 +- .../src/lib/components/table/Table.svelte | 7 +- .../src/lib/components/table/TableCell.svelte | 2 +- .../components/table/TableCellAction.svelte | 4 +- .../components/table/TableCellCheck.svelte | 4 +- .../components/table/TableCellEmpty.svelte | 4 +- .../table/TableDeleteButton2.svelte | 8 +- .../table/TableDropDownButton.svelte | 8 +- .../table/TableDropDownEllipsis.svelte | 25 +- .../src/lib/components/table/TableHead.svelte | 4 +- .../lib/components/table/TableHeadCell.svelte | 8 +- .../table/TableHeadCellAction.svelte | 6 +- .../table/TableHeadCellCheck.svelte | 8 +- .../table/TableHeadCellEmpty.svelte | 3 +- .../table/TableHeadCellSingle.svelte | 6 +- .../src/lib/components/table/TableRow.svelte | 4 +- .../lib/components/table/TableRowEmpty.svelte | 2 +- frontend/src/lib/theme.js | 112 +++---- frontend/src/routes/+layout.svelte | 9 +- frontend/src/routes/api-sender/+page.svelte | 110 ++++--- .../src/routes/campaign-template/+page.svelte | 14 +- frontend/src/routes/campaign/+page.svelte | 152 +++++++--- .../src/routes/campaign/[id]/+page.svelte | 206 ++++++++----- frontend/src/routes/dashboard/+page.svelte | 16 +- frontend/src/routes/domain/+page.svelte | 14 +- frontend/src/routes/install/+page.svelte | 14 +- frontend/src/routes/login/+page.svelte | 53 +++- frontend/src/routes/page/+page.svelte | 12 +- frontend/src/routes/profile/+page.svelte | 60 ++-- frontend/src/routes/settings/+page.svelte | 122 ++++++-- .../routes/smtp-configuration/+page.svelte | 16 +- frontend/tailwind.config.js | 4 + 94 files changed, 1857 insertions(+), 703 deletions(-) create mode 100644 frontend/src/lib/components/ThemeToggle.svelte 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/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} -
+