mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-06-11 16:57:54 +02:00
Add reported functionality
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Vendored
+1
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -6,4 +6,5 @@ type CampaignResultView struct {
|
||||
TrackingPixelLoaded int64 `json:"trackingPixelLoaded"`
|
||||
WebsiteLoaded int64 `json:"clickedLink"`
|
||||
SubmittedData int64 `json:"submittedData"`
|
||||
Reported int64 `json:"reported"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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{}).
|
||||
|
||||
+266
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
Reported by,Date reported(UTC+02:00)
|
||||
alice@black-boat.test,2025-09-17T20:11:24
|
||||
|
@@ -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 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-3 gap-4">
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-blue-600">{chartData[0].openRate}%</div>
|
||||
<div class="text-sm text-gray-600">Open Rate</div>
|
||||
@@ -876,6 +893,10 @@
|
||||
<div class="text-2xl font-bold text-yellow-600">{chartData[0].submissionRate}%</div>
|
||||
<div class="text-sm text-gray-600">Submission Rate</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-indigo-600">{chartData[0].reportRate}%</div>
|
||||
<div class="text-sm text-gray-600">Report Rate</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if hasAttemptedLoad && !isLoading && !debouncedIsLoading && chartData.length >= 2}
|
||||
@@ -926,7 +947,7 @@
|
||||
</div>
|
||||
<div style="height: 1.25rem;"></div>
|
||||
{#if chartData.length > 0}
|
||||
<div class="grid grid-cols-3 gap-2 sm:gap-4">
|
||||
<div class="grid grid-cols-4 gap-2 sm:gap-4">
|
||||
{#each metrics as metric}
|
||||
<div class="text-center">
|
||||
<div class="flex items-center justify-center">
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 = '';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<HeadTitle title="Campaign {campaign.name ? ` - ${campaign.name}` : ''}" />
|
||||
@@ -724,7 +774,9 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-row-1 grid-cols-1 md:grid-cols-2 gap-6 mb-8 mt-4 lg:grid-cols-5">
|
||||
<div
|
||||
class="grid grid-row-1 grid-cols-1 md:grid-cols-2 gap-6 mb-8 mt-4 lg:grid-cols-3 2xl:grid-cols-6"
|
||||
>
|
||||
<StatsCard
|
||||
title="Recipients"
|
||||
value={result.recipients}
|
||||
@@ -902,6 +954,51 @@
|
||||
/>
|
||||
</svg>
|
||||
</StatsCard>
|
||||
|
||||
<StatsCard
|
||||
title="Reported"
|
||||
value={result.reported}
|
||||
borderColor="border-reported"
|
||||
iconColor="text-reported"
|
||||
percentages={[
|
||||
{
|
||||
value: Math.round((result.reported / result.recipients) * 100),
|
||||
relativeTo: 'of recipients',
|
||||
baseValue: result.recipients
|
||||
},
|
||||
{
|
||||
value: Math.round((result.reported / result.emailsSent) * 100),
|
||||
relativeTo: 'of sent',
|
||||
baseValue: result.emailsSent
|
||||
},
|
||||
{
|
||||
value: Math.round((result.reported / result.trackingPixelLoaded) * 100),
|
||||
relativeTo: 'of reads',
|
||||
baseValue: result.trackingPixelLoaded
|
||||
},
|
||||
{
|
||||
value: Math.round((result.reported / result.websiteLoaded) * 100),
|
||||
relativeTo: 'of visits',
|
||||
baseValue: result.websiteLoaded
|
||||
}
|
||||
]}
|
||||
>
|
||||
<svg
|
||||
slot="icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 ml-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.072 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</StatsCard>
|
||||
</div>
|
||||
<div class=" mb-6">
|
||||
<SubHeadline>Event Timeline</SubHeadline>
|
||||
@@ -1092,45 +1189,74 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- Second Row: Schedule Constraints and Actions -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div></div>
|
||||
<div class="bg-white p-6 rounded-lg">
|
||||
<h3 class="text-xl font-semibold text-pc-darkblue mb-4 border-b pb-2">Actions</h3>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
{#if !campaignUpdateDisabledAndTitle(campaign).disabled}
|
||||
<div class="space-y-4">
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#if !campaignUpdateDisabledAndTitle(campaign).disabled}
|
||||
<button
|
||||
on:click={onClickUpdateCampaign}
|
||||
class="px-4 py-2 bg-gradient-to-r from-blue-500 to-blue-600 hover:opacity-80 text-white rounded-md transition-colors"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
on:click={onClickUpdateCampaign}
|
||||
class="px-4 py-2 bg-gradient-to-r from-blue-500 to-blue-600 hover:opacity-80 text-white rounded-md transition-colors"
|
||||
on:click={showCloseCampaignModal}
|
||||
disabled={!!campaign.closedAt}
|
||||
class="px-4 py-2 bg-gradient-to-r from-pc-red to-repeart-submissions hover:opacity-80 text-white rounded-md disabled:opacity-10 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Update
|
||||
Close
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
on:click={showCloseCampaignModal}
|
||||
disabled={!!campaign.closedAt}
|
||||
class="px-4 py-2 bg-gradient-to-r from-pc-red to-repeart-submissions hover:opacity-80 text-white rounded-md disabled:opacity-10 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
on:click={showAnonymizeModal}
|
||||
disabled={!!campaign.anonymizedAt}
|
||||
class="px-4 py-2 bg-gradient-to-r from-pc-red to-repeart-submissions hover:opacity-80 text-white rounded-md disabled:opacity-10 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Anonymize
|
||||
</button>
|
||||
<button
|
||||
on:click={onClickExportEvents}
|
||||
class="px-4 py-2 bg-gradient-to-r from-campaign-active to-campaign-scheduled hover:opacity-80 text-white rounded-md disabled:opacity-10 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Export events
|
||||
</button>
|
||||
<button
|
||||
on:click={onClickExportSubmissions}
|
||||
class="px-4 py-2 bg-gradient-to-r from-campaign-active to-campaign-scheduled hover:opacity-80 text-white rounded-md disabled:opacity-10 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Export submitters
|
||||
</button>
|
||||
<button
|
||||
on:click={showAnonymizeModal}
|
||||
disabled={!!campaign.anonymizedAt}
|
||||
class="px-4 py-2 bg-gradient-to-r from-pc-red to-repeart-submissions hover:opacity-80 text-white rounded-md disabled:opacity-10 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Anonymize
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Export Buttons -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
on:click={onClickExportEvents}
|
||||
class="px-4 py-2 bg-gradient-to-r from-campaign-active to-campaign-scheduled hover:opacity-80 text-white rounded-md disabled:opacity-10 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Export events
|
||||
</button>
|
||||
<button
|
||||
on:click={onClickExportSubmissions}
|
||||
class="px-4 py-2 bg-gradient-to-r from-campaign-active to-campaign-scheduled hover:opacity-80 text-white rounded-md disabled:opacity-10 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Export submitters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Upload Section -->
|
||||
<div class="border-t pt-4">
|
||||
<div>
|
||||
<label
|
||||
for="reported-csv-upload"
|
||||
class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
Upload Reported CSV
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
id="reported-csv-upload"
|
||||
accept=".csv"
|
||||
on:change={onUploadReportedCSV}
|
||||
class="block w-full text-sm text-gray-900 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-reported file:text-white hover:file:opacity-80"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
CSV format: "Reported by" (email), "Date reported(UTC+02:00)"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 @@
|
||||
<TableCell value="{Math.round(stat.openRate)}%" />
|
||||
<TableCell value="{Math.round(stat.clickRate)}%" />
|
||||
<TableCell value="{Math.round(stat.submissionRate)}%" />
|
||||
<TableCell value="{Math.round(stat.reportRate)}%" />
|
||||
<TableCell value={stat.campaignClosedAt} isDate isRelative />
|
||||
</TableRow>
|
||||
{/each}
|
||||
|
||||
@@ -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}
|
||||
<BigButton on:click={onClickExport}>Export events</BigButton>
|
||||
<div>
|
||||
<div class="grid mr-1/12 md:grid-cols-2 lg:grid-cols-6 gap-6 mb-8 mt-4">
|
||||
<div class="grid mr-1/12 md:grid-cols-2 lg:grid-cols-7 gap-6 mb-8 mt-4">
|
||||
<!-- Campaigns card -->
|
||||
<StatsCard
|
||||
title="Campaigns"
|
||||
@@ -258,6 +259,30 @@
|
||||
</svg>
|
||||
</StatsCard>
|
||||
|
||||
<!-- Reported card -->
|
||||
<StatsCard
|
||||
title="Reported"
|
||||
value={stats.campaignsReported}
|
||||
borderColor="border-reported"
|
||||
iconColor="text-reported"
|
||||
>
|
||||
<svg
|
||||
slot="icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 ml-2 text-reported"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.072 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</StatsCard>
|
||||
|
||||
<!-- Repeat link clicks card -->
|
||||
<StatsCard
|
||||
title="Repeat link clicks"
|
||||
|
||||
+10
-12
@@ -30,16 +30,17 @@ export default {
|
||||
'pc-lightblue': '#ddddff',
|
||||
'pc-green': '#5dd8c4ff',
|
||||
'pc-dusty-light-blue': '#819efb',
|
||||
'scheduled': '#e756f6',
|
||||
scheduled: '#e756f6',
|
||||
'recipient-scheduled': '#4e68d8',
|
||||
'failed-sending': '#f2bb58',
|
||||
'cancelled': '#161692',
|
||||
cancelled: '#161692',
|
||||
'message-sent': '#94cae6',
|
||||
'message-read': '#4cb5b5',
|
||||
'before-page-visited': '#eea5fa',
|
||||
'page-visited': '#f96dcf',
|
||||
'after-page-visited': '#f6287b',
|
||||
'submitted-data': '#f42e41',
|
||||
reported: '#2c3e50',
|
||||
'completed-campaign': '#48bb78',
|
||||
'repeat-offenders': '#ff6768',
|
||||
'emails-read': '#e8e810',
|
||||
@@ -47,15 +48,13 @@ export default {
|
||||
'repeat-link-clicks': '#c78bfd',
|
||||
'data-submitted': '#f42e41',
|
||||
'repeart-submissions': '#ff6a91',
|
||||
'recipients': '#6cd4cc',
|
||||
'closed': '#9f9f9f',
|
||||
recipients: '#6cd4cc',
|
||||
closed: '#9f9f9f',
|
||||
'campaign-active': '#5557f6',
|
||||
'campaign-scheduled':'#62aded',
|
||||
'campaign-completed':'#69e1ab',
|
||||
|
||||
|
||||
'campaign-scheduled': '#62aded',
|
||||
'campaign-completed': '#69e1ab'
|
||||
},
|
||||
/*
|
||||
/*
|
||||
'scheduled':'#e756f6',
|
||||
'recipient-scheduled':'#4e68d8',
|
||||
'failed-sending':'#4cb5b5',
|
||||
@@ -87,7 +86,7 @@ export default {
|
||||
'67vh': '67vh',
|
||||
'70vh': '70vh',
|
||||
'75vh': '75vh',
|
||||
'80vh': '80vh',
|
||||
'80vh': '80vh'
|
||||
},
|
||||
width: {
|
||||
'1/10': '10%',
|
||||
@@ -96,8 +95,7 @@ export default {
|
||||
'70vw': '70vw',
|
||||
'80vw': '80vw',
|
||||
'90vw': '90vw'
|
||||
},
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user