mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-02-12 16:12:44 +00:00
1180 lines
29 KiB
Go
1180 lines
29 KiB
Go
package controller
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/csv"
|
|
"io"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-errors/errors"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/phishingclub/phishingclub/build"
|
|
"github.com/phishingclub/phishingclub/cache"
|
|
"github.com/phishingclub/phishingclub/data"
|
|
"github.com/phishingclub/phishingclub/database"
|
|
"github.com/phishingclub/phishingclub/embedded"
|
|
"github.com/phishingclub/phishingclub/errs"
|
|
"github.com/phishingclub/phishingclub/model"
|
|
"github.com/phishingclub/phishingclub/repository"
|
|
"github.com/phishingclub/phishingclub/service"
|
|
"github.com/phishingclub/phishingclub/utils"
|
|
)
|
|
|
|
// allowedCampaignColumns is a map between the frontend and the backend
|
|
// so the frontend has user friendly names instead of direct references
|
|
// to the database schema
|
|
// this is tied to a slice in the repository package
|
|
var allowedCampaignColumns = map[string]string{
|
|
"created_at": repository.TableColumn(database.CAMPAIGN_TABLE, "created_at"),
|
|
"updated_at": repository.TableColumn(database.CAMPAIGN_TABLE, "updated_at"),
|
|
"closed_at": repository.TableColumn(database.CAMPAIGN_TABLE, "closed_at"),
|
|
"close_at": repository.TableColumn(database.CAMPAIGN_TABLE, "close_at"),
|
|
"anonymized_at": repository.TableColumn(database.CAMPAIGN_TABLE, "anonymized_at"),
|
|
"is_test": repository.TableColumn(database.CAMPAIGN_TABLE, "is_test"),
|
|
"send_start_at": repository.TableColumn(database.CAMPAIGN_TABLE, "send_start_at"),
|
|
"send_end_at": repository.TableColumn(database.CAMPAIGN_TABLE, "send_end_at"),
|
|
"template": repository.TableColumn(database.CAMPAIGN_TEMPLATE_TABLE, "name"),
|
|
"name": repository.TableColumn(database.CAMPAIGN_TABLE, "name"),
|
|
}
|
|
|
|
// campaignEventColumns is a map between the frontend and the backend
|
|
// so the frontend has user friendly names instead of direct references
|
|
// to the database schema
|
|
// this is tied to a slice in the repository package
|
|
var campaignEventColumns = map[string]string{
|
|
"created_at": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "created_at"),
|
|
"updated_at": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "updated_at"),
|
|
"details": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "data"),
|
|
"ip": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "ip_address"),
|
|
"user-agent": repository.TableColumn(database.CAMPAIGN_EVENT_TABLE, "user_agent"),
|
|
"email": repository.TableColumn(database.RECIPIENT_TABLE, "email"),
|
|
"first_name": repository.TableColumn(database.RECIPIENT_TABLE, "first_name"),
|
|
"last_name": repository.TableColumn(database.RECIPIENT_TABLE, "last_name"),
|
|
"event": repository.TableColumn(database.EVENT_TABLE, "name"),
|
|
}
|
|
|
|
// allowedCampaignRecipientColumns is a map between the frontend and the backend
|
|
// so the frontend has user friendly names instead of direct references
|
|
// to the database schema
|
|
// this is tied to a slice in the repository package
|
|
var allowedCampaignRecipientColumns = map[string]string{
|
|
"created_at": "campaign_recipients.created_at",
|
|
"updated_at": "campaign_recipients.updated_at",
|
|
"send_at": "campaign_recipients.send_at",
|
|
"sent_at": "campaign_recipients.sent_at",
|
|
"cancelled_at": "campaign_recipients.cancelled_at",
|
|
"status": "campaign_recipients.notable_event_id",
|
|
"first_name": "recipients.first_name",
|
|
"last_name": "recipients.last_name",
|
|
"email": "recipients.email",
|
|
}
|
|
|
|
// Campaign is a Campaign controller
|
|
type Campaign struct {
|
|
Common
|
|
CampaignService *service.Campaign
|
|
}
|
|
|
|
// CloseCampaignByID closes campaign
|
|
func (c *Campaign) CloseCampaignByID(g *gin.Context) {
|
|
// handle session
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, ok := c.handleParseIDParam(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// close campaigns
|
|
err := c.CampaignService.CloseCampaignByID(
|
|
g.Request.Context(),
|
|
session,
|
|
id,
|
|
)
|
|
// handle responses
|
|
if errors.Is(err, errs.ErrCampaignAlreadyClosed) {
|
|
c.Response.ValidationFailed(g, "", err)
|
|
return
|
|
}
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
c.Response.OK(g, gin.H{})
|
|
}
|
|
|
|
// Create creates a new campaign
|
|
func (c *Campaign) Create(g *gin.Context) {
|
|
// handle session
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse req
|
|
var req model.Campaign
|
|
if ok := c.handleParseRequest(g, &req); !ok {
|
|
return
|
|
}
|
|
// create and schedule the campaign
|
|
id, err := c.CampaignService.Create(g.Request.Context(), session, &req)
|
|
// handle responses
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
c.Response.OK(g, gin.H{
|
|
"id": id.String(),
|
|
})
|
|
}
|
|
|
|
// GetAllEventTypes gets all event types
|
|
func (c *Campaign) GetAllEventTypes(g *gin.Context) {
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// check permissions
|
|
isAuthorized, err := service.IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
|
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
|
_ = handleServerError(g, c.Response, err)
|
|
return
|
|
}
|
|
if !isAuthorized {
|
|
c.Response.Unauthorized(g)
|
|
return
|
|
}
|
|
// get all event names
|
|
// we pick them out from the in memory cache
|
|
ev := []gin.H{}
|
|
for name, id := range cache.EventIDByName {
|
|
ev = append(ev, gin.H{
|
|
"id": id,
|
|
"name": name,
|
|
})
|
|
}
|
|
c.Response.OK(g, ev)
|
|
}
|
|
|
|
// GetByID gets a campaign by its id
|
|
func (c *Campaign) GetByID(g *gin.Context) {
|
|
// handle session
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
id, ok := c.handleParseIDParam(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// get the campaign that needs to be updated
|
|
campaign, err := c.CampaignService.GetByID(
|
|
g.Request.Context(),
|
|
session,
|
|
id,
|
|
&repository.CampaignOption{
|
|
WithRecipientGroups: true,
|
|
WithAllowDeny: true,
|
|
WithDenyPage: true,
|
|
WithEvasionPage: true,
|
|
},
|
|
)
|
|
// handle responses
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
c.Response.OK(g, campaign)
|
|
}
|
|
|
|
// GetByName gets a campaign by name
|
|
func (c *Campaign) GetByName(g *gin.Context) {
|
|
// handle session
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
companyID := companyIDFromRequestQuery(g)
|
|
name := g.Param("name")
|
|
if !ok {
|
|
return
|
|
}
|
|
// get the campaign that needs to be updated
|
|
campaign, err := c.CampaignService.GetByName(
|
|
g,
|
|
session,
|
|
name,
|
|
companyID,
|
|
&repository.CampaignOption{
|
|
WithRecipientGroups: true,
|
|
WithAllowDeny: true,
|
|
WithDenyPage: true,
|
|
WithEvasionPage: true,
|
|
},
|
|
)
|
|
// handle responses
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
c.Response.OK(g, campaign)
|
|
}
|
|
|
|
// GetResultStats get campaign result stats
|
|
func (c *Campaign) GetResultStats(g *gin.Context) {
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
id, ok := c.handleParseIDParam(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// get
|
|
stats, err := c.CampaignService.GetResultStats(
|
|
g.Request.Context(),
|
|
session,
|
|
id,
|
|
)
|
|
// handle responses
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
c.Response.OK(g, stats)
|
|
}
|
|
|
|
// GetCampaignStats get campaign stats
|
|
// if no company id is provided it gets the global stats including all companies
|
|
func (c *Campaign) GetStats(g *gin.Context) {
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
companyID := companyIDFromRequestQuery(g)
|
|
includeTestCampaigns := g.Query("includeTest") == "true"
|
|
// get
|
|
stats, err := c.CampaignService.GetStats(
|
|
g.Request.Context(),
|
|
session,
|
|
companyID,
|
|
includeTestCampaigns,
|
|
)
|
|
// handle responses
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
c.Response.OK(g, stats)
|
|
}
|
|
|
|
// GetAll gets all campaigns with pagination
|
|
func (c *Campaign) GetAll(g *gin.Context) {
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
companyID := companyIDFromRequestQuery(g)
|
|
// default to including test campaigns for backward compatibility
|
|
// only exclude when explicitly set to "false"
|
|
includeTestCampaigns := g.Query("includeTest") != "false"
|
|
queryArgs, ok := c.handleQueryArgs(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
queryArgs.RemapOrderBy(allowedCampaignColumns)
|
|
queryArgs.DefaultSortByUpdatedAt()
|
|
// get all campaigns
|
|
campaigns, err := c.CampaignService.GetAll(
|
|
g.Request.Context(),
|
|
session,
|
|
companyID,
|
|
&repository.CampaignOption{
|
|
QueryArgs: queryArgs,
|
|
WithCampaignTemplate: true,
|
|
IncludeTestCampaigns: includeTestCampaigns,
|
|
},
|
|
)
|
|
// handle responses
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
c.Response.OK(g, campaigns)
|
|
|
|
}
|
|
|
|
// GetAll gets all campaigns within dates
|
|
func (c *Campaign) GetAllWithinDates(g *gin.Context) {
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
companyID := companyIDFromRequestQuery(g)
|
|
includeTestCampaigns := g.Query("includeTest") == "true"
|
|
queryArgs, ok := c.handleQueryArgs(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
queryArgs.RemapOrderBy(allowedCampaignColumns)
|
|
queryArgs.DefaultSortByUpdatedAt()
|
|
// get start and end date for query
|
|
startDate, err := time.Parse(time.RFC3339Nano, g.Query("start"))
|
|
if err != nil {
|
|
c.Response.ValidationFailed(g, "start", err)
|
|
return
|
|
}
|
|
endDate, err := time.Parse(time.RFC3339Nano, g.Query("end"))
|
|
if err != nil {
|
|
c.Response.ValidationFailed(g, "end", err)
|
|
return
|
|
}
|
|
// get all campaigns
|
|
campaigns, err := c.CampaignService.GetAllWithinDates(
|
|
g.Request.Context(),
|
|
session,
|
|
startDate,
|
|
endDate,
|
|
companyID,
|
|
&repository.CampaignOption{
|
|
QueryArgs: queryArgs,
|
|
WithCompany: true,
|
|
WithCampaignTemplate: true,
|
|
IncludeTestCampaigns: includeTestCampaigns,
|
|
},
|
|
)
|
|
// handle responses
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
c.Response.OK(g, campaigns)
|
|
|
|
}
|
|
|
|
// GetAllActive gets all active campaigns with pagination
|
|
// if no company id is given it gets all globals including company
|
|
func (c *Campaign) GetAllActive(g *gin.Context) {
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
companyID := companyIDFromRequestQuery(g)
|
|
includeTestCampaigns := g.Query("includeTest") == "true"
|
|
queryArgs, ok := c.handleQueryArgs(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
queryArgs.RemapOrderBy(allowedCampaignColumns)
|
|
if queryArgs.OrderBy == "" {
|
|
queryArgs.OrderBy = "send_start_at"
|
|
queryArgs.Desc = false
|
|
}
|
|
// get all campaigns
|
|
campaigns, err := c.CampaignService.GetAllActive(
|
|
g.Request.Context(),
|
|
session,
|
|
companyID,
|
|
&repository.CampaignOption{
|
|
QueryArgs: queryArgs,
|
|
WithCompany: true,
|
|
WithCampaignTemplate: true,
|
|
IncludeTestCampaigns: includeTestCampaigns,
|
|
},
|
|
)
|
|
// handle responses
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
c.Response.OK(g, campaigns)
|
|
}
|
|
|
|
// GetAllUpcoming gets all upcoming campaigns with pagination
|
|
// if no company id is given it gets all globals including company
|
|
func (c *Campaign) GetAllUpcoming(g *gin.Context) {
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
companyID := companyIDFromRequestQuery(g)
|
|
includeTestCampaigns := g.Query("includeTest") == "true"
|
|
queryArgs, ok := c.handleQueryArgs(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
queryArgs.RemapOrderBy(allowedCampaignColumns)
|
|
if queryArgs.OrderBy == "" {
|
|
queryArgs.OrderBy = "send_start_at"
|
|
queryArgs.Desc = false
|
|
}
|
|
// get all campaigns
|
|
campaigns, err := c.CampaignService.GetAllUpcoming(
|
|
g.Request.Context(),
|
|
session,
|
|
companyID,
|
|
&repository.CampaignOption{
|
|
QueryArgs: queryArgs,
|
|
WithCompany: true,
|
|
WithCampaignTemplate: true,
|
|
IncludeTestCampaigns: includeTestCampaigns,
|
|
},
|
|
)
|
|
// handle responses
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
c.Response.OK(g, campaigns)
|
|
}
|
|
|
|
// GetAllFinished gets all finished campaigns with pagination
|
|
// if no company id is given it gets all globals including company
|
|
func (c *Campaign) GetAllFinished(g *gin.Context) {
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
companyID := companyIDFromRequestQuery(g)
|
|
includeTestCampaigns := g.Query("includeTest") == "true"
|
|
queryArgs, ok := c.handleQueryArgs(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
queryArgs.RemapOrderBy(allowedCampaignColumns)
|
|
if queryArgs.OrderBy == "" {
|
|
queryArgs.OrderBy = "send_start_at"
|
|
queryArgs.Desc = true
|
|
}
|
|
// get all campaigns
|
|
campaigns, err := c.CampaignService.GetAllFinished(
|
|
g.Request.Context(),
|
|
session,
|
|
companyID,
|
|
&repository.CampaignOption{
|
|
QueryArgs: queryArgs,
|
|
WithCompany: true,
|
|
WithCampaignTemplate: true,
|
|
IncludeTestCampaigns: includeTestCampaigns,
|
|
},
|
|
)
|
|
// handle responses
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
c.Response.OK(g, campaigns)
|
|
}
|
|
|
|
// GetEventsByCampaignID gets events by campaign id
|
|
func (c *Campaign) GetEventsByCampaignID(g *gin.Context) {
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
id, ok := c.handleParseIDParam(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
queryArgs, ok := c.handleQueryArgs(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
queryArgs.DefaultSortByUpdatedAt()
|
|
// remap query args
|
|
queryArgs.RemapOrderBy(campaignEventColumns)
|
|
// set default sort order to desc
|
|
sortOrder := g.DefaultQuery("sortOrder", "desc")
|
|
if sortOrder == "desc" {
|
|
queryArgs.Desc = true
|
|
}
|
|
var since *time.Time
|
|
s, err := time.Parse(time.RFC3339Nano, g.Query("since"))
|
|
if err == nil {
|
|
since = &s
|
|
}
|
|
// get events by campaign id
|
|
events, err := c.CampaignService.GetEventsByCampaignID(
|
|
g.Request.Context(),
|
|
session,
|
|
id,
|
|
queryArgs,
|
|
since,
|
|
nil,
|
|
)
|
|
// handle responses
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
c.Response.OK(g, events)
|
|
}
|
|
|
|
// ExportEventsAsCSV exports a all campaign events as a CSV
|
|
func (c *Campaign) ExportEventsAsCSV(g *gin.Context) {
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
id, ok := c.handleParseIDParam(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
queryArgs, ok := c.handleQueryArgs(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
queryArgs.DefaultSortByCreatedAt()
|
|
queryArgs.RemapOrderBy(campaignEventColumns)
|
|
sortOrder := g.DefaultQuery("sortOrder", "desc")
|
|
if sortOrder == "desc" {
|
|
queryArgs.Desc = true
|
|
}
|
|
// get all rows
|
|
queryArgs.Limit = 0
|
|
queryArgs.Offset = 0
|
|
// get events by campaign id
|
|
events, err := c.CampaignService.GetEventsByCampaignID(
|
|
g.Request.Context(),
|
|
session,
|
|
id,
|
|
queryArgs,
|
|
nil,
|
|
nil,
|
|
)
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
|
|
buffer := &bytes.Buffer{}
|
|
writer := csv.NewWriter(buffer)
|
|
|
|
headers := []string{
|
|
"Created at",
|
|
"Recipient name",
|
|
"Recipient email",
|
|
"Event name",
|
|
"Event Details",
|
|
"User-Agent",
|
|
"IP",
|
|
}
|
|
err = writer.Write(headers)
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
for _, event := range events.Rows {
|
|
row := []string{}
|
|
// if the recipient has been deleted or anonymized
|
|
if event.Recipient == nil {
|
|
row = []string{
|
|
utils.CSVFromDate(event.CreatedAt),
|
|
"anonymized",
|
|
"anonymized",
|
|
utils.CSVRemoveFormulaStart(cache.EventNameByID[event.EventID.String()]),
|
|
utils.CSVRemoveFormulaStart(event.Data.String()),
|
|
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
|
|
utils.CSVRemoveFormulaStart(event.IP.String()),
|
|
}
|
|
} else {
|
|
row = []string{
|
|
utils.CSVFromDate(event.CreatedAt),
|
|
utils.CSVRemoveFormulaStart(event.Recipient.FirstName.MustGet().String()),
|
|
utils.CSVRemoveFormulaStart(event.Recipient.LastName.MustGet().String()),
|
|
utils.CSVRemoveFormulaStart(event.Recipient.Email.MustGet().String()),
|
|
utils.CSVRemoveFormulaStart(cache.EventNameByID[event.EventID.String()]),
|
|
utils.CSVRemoveFormulaStart(event.Data.String()),
|
|
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
|
|
utils.CSVRemoveFormulaStart(event.IP.String()),
|
|
}
|
|
}
|
|
err = writer.Write(row)
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
}
|
|
c.responseWithCSV(g, buffer, writer, "campaign_events.csv")
|
|
}
|
|
|
|
// ExportSubmissionsAsCSV exports all campaign submissions as a CSV
|
|
func (c *Campaign) ExportSubmissionsAsCSV(g *gin.Context) {
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
id, ok := c.handleParseIDParam(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
queryArgs, ok := c.handleQueryArgs(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
queryArgs.DefaultSortByCreatedAt()
|
|
queryArgs.RemapOrderBy(campaignEventColumns)
|
|
sortOrder := g.DefaultQuery("sortOrder", "desc")
|
|
if sortOrder == "desc" {
|
|
queryArgs.Desc = true
|
|
}
|
|
// get all rows
|
|
queryArgs.Limit = 0
|
|
queryArgs.Offset = 0
|
|
|
|
// filter for submission events only
|
|
submissionEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA]
|
|
eventTypeFilter := []string{submissionEventID.String()}
|
|
|
|
// get submission events by campaign id
|
|
events, err := c.CampaignService.GetEventsByCampaignID(
|
|
g.Request.Context(),
|
|
session,
|
|
id,
|
|
queryArgs,
|
|
nil,
|
|
eventTypeFilter,
|
|
)
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
|
|
buffer := &bytes.Buffer{}
|
|
writer := csv.NewWriter(buffer)
|
|
|
|
headers := []string{
|
|
"Submitted at",
|
|
"Recipient first name",
|
|
"Recipient last name",
|
|
"Recipient email",
|
|
"Submitted data",
|
|
"User-Agent",
|
|
"IP",
|
|
}
|
|
err = writer.Write(headers)
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
for _, event := range events.Rows {
|
|
row := []string{}
|
|
// if the recipient has been deleted or anonymized
|
|
if event.Recipient == nil {
|
|
row = []string{
|
|
utils.CSVFromDate(event.CreatedAt),
|
|
"anonymized",
|
|
"anonymized",
|
|
"anonymized",
|
|
utils.CSVRemoveFormulaStart(event.Data.String()),
|
|
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
|
|
utils.CSVRemoveFormulaStart(event.IP.String()),
|
|
}
|
|
} else {
|
|
row = []string{
|
|
utils.CSVFromDate(event.CreatedAt),
|
|
utils.CSVRemoveFormulaStart(event.Recipient.FirstName.MustGet().String()),
|
|
utils.CSVRemoveFormulaStart(event.Recipient.LastName.MustGet().String()),
|
|
utils.CSVRemoveFormulaStart(event.Recipient.Email.MustGet().String()),
|
|
utils.CSVRemoveFormulaStart(event.Data.String()),
|
|
utils.CSVRemoveFormulaStart(event.UserAgent.String()),
|
|
utils.CSVRemoveFormulaStart(event.IP.String()),
|
|
}
|
|
}
|
|
err = writer.Write(row)
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
}
|
|
c.responseWithCSV(g, buffer, writer, "campaign_submissions.csv")
|
|
}
|
|
|
|
func (c *Campaign) GetCampaignEmail(g *gin.Context) {
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
id, ok := c.handleParseIDParam(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// get email
|
|
email, err := c.CampaignService.GetCampaignEmailBody(
|
|
g.Request.Context(),
|
|
session,
|
|
id,
|
|
)
|
|
// handle responses
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
c.Response.OK(g, email)
|
|
}
|
|
|
|
// GetCampaignURL gets a recipient landing page URL
|
|
func (c *Campaign) GetCampaignURL(g *gin.Context) {
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
id, ok := c.handleParseIDParam(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
url, err := c.CampaignService.GetLandingPageURLByCampaignRecipientID(
|
|
g.Request.Context(),
|
|
session,
|
|
id,
|
|
)
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
c.Response.OK(g, url)
|
|
}
|
|
|
|
// GetRecipientsByCampaignID gets recipients by campaign id
|
|
func (c *Campaign) GetRecipientsByCampaignID(g *gin.Context) {
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// endpoints is handled a bit differently and allows to
|
|
// fetch an unlimited amount of rows if no offset and limit is set.
|
|
offset := g.DefaultQuery("offset", "")
|
|
limit := g.DefaultQuery("limit", "")
|
|
// parse request
|
|
id, ok := c.handleParseIDParam(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
queryArgs, ok := c.handleQueryArgs(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// special case to retrieve ALL rows
|
|
if offset == "" && limit == "" {
|
|
queryArgs.Offset = 0
|
|
queryArgs.Limit = 0
|
|
}
|
|
// remap query args
|
|
queryArgs.DefaultSortBy("created_at")
|
|
queryArgs.RemapOrderBy(allowedCampaignRecipientColumns)
|
|
// get recipients by campaign id
|
|
recipients, err := c.CampaignService.GetRecipientsByCampaignID(
|
|
g.Request.Context(),
|
|
session,
|
|
id,
|
|
&repository.CampaignRecipientOption{
|
|
QueryArgs: queryArgs,
|
|
WithRecipient: true,
|
|
},
|
|
)
|
|
// handle responses
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
c.Response.OK(g, recipients)
|
|
}
|
|
|
|
// TrackingPixel returns a tracking pixel
|
|
func (c *Campaign) TrackingPixel(g *gin.Context) {
|
|
// get the campaign recipient id from the query
|
|
campaignRecipientID := g.Query("upn") // expect the campaign recipient id to be in here
|
|
if campaignRecipientID == "" {
|
|
c.Response.NotFound(g)
|
|
return
|
|
}
|
|
campaignRecipientUUID, err := uuid.Parse(campaignRecipientID)
|
|
if err != nil {
|
|
c.Logger.Debugw(errs.MsgFailedToParseRequest,
|
|
"error", err,
|
|
)
|
|
c.Response.NotFound(g)
|
|
return
|
|
}
|
|
err = c.CampaignService.SaveTrackingPixelLoaded(
|
|
g,
|
|
&campaignRecipientUUID,
|
|
)
|
|
if err != nil {
|
|
c.Logger.Debugw("failed to save tracking pixel loaded event",
|
|
"error", err,
|
|
)
|
|
c.Response.NotFound(g)
|
|
return
|
|
}
|
|
g.Header("Content-Type", "image/gif")
|
|
if !build.Flags.Production {
|
|
g.File("./embedded/tracking-pixel/sendgrid/open.gif")
|
|
return
|
|
}
|
|
_, err = g.Writer.Write(embedded.TrackingPixel)
|
|
if err != nil {
|
|
c.Logger.Errorw("failed to write tracking pixel", "error", err)
|
|
}
|
|
}
|
|
|
|
// UpdateByID updates a campaign by its id
|
|
func (c *Campaign) UpdateByID(g *gin.Context) {
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
id, ok := c.handleParseIDParam(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req model.Campaign
|
|
if ok := c.handleParseRequest(g, &req); !ok {
|
|
return
|
|
}
|
|
// update the campaign
|
|
err := c.CampaignService.UpdateByID(g.Request.Context(), session, id, &req)
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
// handle responses
|
|
c.Response.OK(g, gin.H{})
|
|
}
|
|
|
|
// SetSentAtByCampaignRecipientID sets the sent at time for a campaign recipient
|
|
func (c *Campaign) SetSentAtByCampaignRecipientID(g *gin.Context) {
|
|
// handle session
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
id, ok := c.handleParseIDParam(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// set sent at time
|
|
err := c.CampaignService.SetSentAtByCampaignRecipientID(g.Request.Context(), session, id)
|
|
// handle responses
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
c.Response.OK(g, gin.H{})
|
|
}
|
|
|
|
// SendEmailByCampaignRecipientID sends an email to a specific campaign recipient
|
|
func (c *Campaign) SendEmailByCampaignRecipientID(g *gin.Context) {
|
|
// handle session
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
id, ok := c.handleParseIDParam(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// send message (email or API depending on campaign template configuration)
|
|
err := c.CampaignService.SendEmailByCampaignRecipientID(g.Request.Context(), session, id)
|
|
// handle responses - sending failures are expected (invalid recipient email etc)
|
|
// so return as bad request instead of internal server error
|
|
if err != nil {
|
|
c.Response.BadRequestMessage(g, err.Error())
|
|
return
|
|
}
|
|
c.Response.OK(g, gin.H{})
|
|
}
|
|
|
|
// DeleteByID deletes a campaign by its id
|
|
func (c *Campaign) DeleteByID(g *gin.Context) {
|
|
// handle session
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
id, ok := c.handleParseIDParam(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// delete
|
|
err := c.CampaignService.DeleteByID(g, session, id)
|
|
// handle responses
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
c.Response.OK(g, gin.H{})
|
|
}
|
|
|
|
// DeleteEventByID deletes a single campaign event by its id
|
|
func (c *Campaign) DeleteEventByID(g *gin.Context) {
|
|
// handle session
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
id, ok := c.handleParseIDParam(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// delete event
|
|
err := c.CampaignService.DeleteEventByID(g.Request.Context(), session, id)
|
|
// handle responses
|
|
if errors.Is(err, errs.ErrCampaignAlreadyClosed) {
|
|
c.Response.ValidationFailed(g, "", err)
|
|
return
|
|
}
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
c.Response.OK(g, gin.H{})
|
|
}
|
|
|
|
// AnonymizeByID anonymizes a campaign by its id
|
|
func (c *Campaign) AnonymizeByID(g *gin.Context) {
|
|
// handle session
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
id, ok := c.handleParseIDParam(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// anonymize
|
|
err := c.CampaignService.AnonymizeByID(g, session, id)
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
c.Response.OK(g, gin.H{})
|
|
}
|
|
|
|
// GetCampaignStats gets campaign statistics by campaign ID
|
|
func (c *Campaign) GetCampaignStats(g *gin.Context) {
|
|
// handle session
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
id, ok := c.handleParseIDParam(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// get stats
|
|
stats, err := c.CampaignService.GetCampaignStats(g.Request.Context(), session, id)
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
c.Response.OK(g, stats)
|
|
}
|
|
|
|
// GetAllCampaignStats gets all campaign statistics with pagination
|
|
func (c *Campaign) GetAllCampaignStats(g *gin.Context) {
|
|
// handle session
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
companyID := companyIDFromRequestQuery(g)
|
|
|
|
// get stats
|
|
stats, err := c.CampaignService.GetAllCampaignStats(g.Request.Context(), session, companyID)
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
c.Response.OK(g, stats)
|
|
}
|
|
|
|
// CreateCampaignStats creates manual campaign statistics
|
|
func (c *Campaign) CreateCampaignStats(g *gin.Context) {
|
|
// handle session
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// parse request body
|
|
var req database.CampaignStats
|
|
|
|
if err := g.ShouldBindJSON(&req); err != nil {
|
|
c.Response.BadRequest(g)
|
|
return
|
|
}
|
|
|
|
// create stats
|
|
stats, err := c.CampaignService.CreateManualCampaignStats(g.Request.Context(), session, &req)
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
|
|
c.Response.OK(g, stats)
|
|
}
|
|
|
|
// GetManualCampaignStats gets all manual campaign statistics (those without campaignID)
|
|
func (c *Campaign) GetManualCampaignStats(g *gin.Context) {
|
|
// handle session
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
companyID := companyIDFromRequestQuery(g)
|
|
|
|
// get manual stats (those with null campaignID)
|
|
stats, err := c.CampaignService.GetManualCampaignStats(g.Request.Context(), session, companyID)
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
c.Response.OK(g, stats)
|
|
}
|
|
|
|
// UpdateCampaignStats updates manual campaign statistics by ID
|
|
func (c *Campaign) UpdateCampaignStats(g *gin.Context) {
|
|
// handle session
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
id, ok := c.handleParseIDParam(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// parse request body
|
|
var req database.CampaignStats
|
|
|
|
if err := g.ShouldBindJSON(&req); err != nil {
|
|
c.Response.BadRequest(g)
|
|
return
|
|
}
|
|
|
|
// update stats
|
|
req.ID = id
|
|
stats, err := c.CampaignService.UpdateManualCampaignStats(g.Request.Context(), session, &req)
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
|
|
c.Response.OK(g, stats)
|
|
}
|
|
|
|
// DeleteCampaignStatsManual deletes manual campaign statistics by ID
|
|
func (c *Campaign) DeleteCampaignStatsManual(g *gin.Context) {
|
|
// handle session
|
|
session, _, ok := c.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
id, ok := c.handleParseIDParam(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// delete manual stats
|
|
err := c.CampaignService.DeleteManualCampaignStats(g.Request.Context(), session, id)
|
|
if ok := c.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
|
|
c.Response.OK(g, gin.H{"message": "Campaign stats deleted successfully"})
|
|
}
|
|
|
|
// 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])
|
|
|
|
// get required column indices from query params
|
|
emailColStr := g.Query("emailColumn")
|
|
if emailColStr == "" {
|
|
c.Response.ValidationFailed(g, "emailColumn", errors.New("emailColumn query parameter is required"))
|
|
return
|
|
}
|
|
|
|
dateColStr := g.Query("dateColumn")
|
|
if dateColStr == "" {
|
|
c.Response.ValidationFailed(g, "dateColumn", errors.New("dateColumn query parameter is required"))
|
|
return
|
|
}
|
|
|
|
emailColIdx, err := strconv.Atoi(emailColStr)
|
|
if err != nil || emailColIdx < 0 {
|
|
c.Response.ValidationFailed(g, "emailColumn", errors.New("emailColumn must be a valid non-negative integer"))
|
|
return
|
|
}
|
|
|
|
dateColIdx, err := strconv.Atoi(dateColStr)
|
|
if err != nil || dateColIdx < 0 {
|
|
c.Response.ValidationFailed(g, "dateColumn", errors.New("dateColumn must be a valid non-negative integer"))
|
|
return
|
|
}
|
|
|
|
c.Logger.Debugw("using provided column indices", "emailColumn", emailColIdx, "dateColumn", dateColIdx)
|
|
|
|
// process CSV
|
|
processed, skipped, err := c.CampaignService.ProcessReportedCSV(g.Request.Context(), session, id, records, emailColIdx, dateColIdx)
|
|
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",
|
|
})
|
|
}
|