package service
import (
"bytes"
"encoding/base64"
"fmt"
"html"
"io"
"math/rand"
"strings"
"text/template"
"time"
"github.com/go-errors/errors"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/utils"
"github.com/phishingclub/phishingclub/vo"
"github.com/yeqown/go-qrcode/v2"
)
const trackingPixelTemplate = "{{.Tracker}}"
// TemplateService is for handling things related to
// templates such as websites, emails, etc.
type Template struct {
Common
}
// CreateMailTemplate creates a new mail template
func (t *Template) CreateMail(
domainName string,
idKey string,
urlPath string,
campaignRecipient *model.CampaignRecipient,
email *model.Email,
apiSender *model.APISender,
) *map[string]any {
baseURL := "https://" + domainName
url := fmt.Sprintf(
"%s%s?%s=%s",
baseURL,
urlPath,
idKey,
campaignRecipient.ID.MustGet().String(),
)
// set body
trackingPixelPath := fmt.Sprintf(
"%s/wf/open?upn=%s",
baseURL,
campaignRecipient.ID.MustGet().String(),
)
trackingPixel := fmt.Sprintf(
"",
baseURL,
campaignRecipient.ID.MustGet().String(),
)
return t.newTemplateDataMap(
idKey,
baseURL,
url,
campaignRecipient.Recipient,
trackingPixelPath,
trackingPixel,
email,
apiSender,
)
}
// ValidatePageTemplate validates that a page template can be parsed and executed without errors
func (t *Template) ValidatePageTemplate(content string) error {
_, err := template.New("validation").
Funcs(TemplateFuncs()).
Parse(content)
if err != nil {
return fmt.Errorf("failed to parse page template: %s", err)
}
// also try to execute with mock data to catch runtime errors
_, err = t.ApplyPageMock(content)
if err != nil {
return fmt.Errorf("failed to execute page template: %s", err)
}
return nil
}
// ValidateEmailTemplate validates that an email template can be parsed and executed without errors
func (t *Template) ValidateEmailTemplate(content string) error {
_, err := template.New("validation").
Funcs(TemplateFuncs()).
Parse(content)
if err != nil {
return fmt.Errorf("failed to parse email template: %s", err)
}
// also try to execute with mock data to catch runtime errors
domain := &model.Domain{
Name: nullable.NewNullableWithValue(
*vo.NewString255Must("example.test"),
),
}
recipient := model.NewRecipientExample()
campaignRecipient := model.CampaignRecipient{
ID: nullable.NewNullableWithValue(
uuid.New(),
),
Recipient: recipient,
}
email := model.NewEmailExample()
email.Content = nullable.NewNullableWithValue(
*vo.NewUnsafeOptionalString1MB(content),
)
apiSender := model.NewAPISenderExample()
_, err = t.CreateMailBody(
"id",
"/test",
domain,
&campaignRecipient,
email,
apiSender,
)
if err != nil {
return fmt.Errorf("failed to execute email template: %s", err)
}
return nil
}
// ValidateDomainTemplate validates that a domain template can be parsed and executed without errors
func (t *Template) ValidateDomainTemplate(content string) error {
_, err := template.New("validation").
Funcs(TemplateFuncs()).
Parse(content)
if err != nil {
return fmt.Errorf("failed to parse domain template: %s", err)
}
// also try to execute with mock data to catch runtime errors
// domains only have access to BaseURL variable
data := map[string]any{
"BaseURL": "https://example.test",
}
tmpl, err := template.New("domain").
Funcs(TemplateFuncs()).
Parse(content)
if err != nil {
return fmt.Errorf("failed to parse domain template: %s", err)
}
var buf bytes.Buffer
err = tmpl.Execute(&buf, data)
if err != nil {
return fmt.Errorf("failed to execute domain template: %s", err)
}
return nil
}
// ApplyPageMock
func (t *Template) ApplyPageMock(content string) (*bytes.Buffer, error) {
// build response
domain := &database.Domain{
Name: "example.test",
}
email := model.NewEmailExample()
campaignRecipientID := uuid.New()
recipient := model.NewRecipientExample()
urlIdentifier := &model.Identifier{
Name: nullable.NewNullableWithValue(
"id",
),
}
stateIdentifier := &model.Identifier{
Name: nullable.NewNullableWithValue(
"state",
),
}
campaignTemplate := &model.CampaignTemplate{
URLIdentifier: urlIdentifier,
StateIdentifier: stateIdentifier,
}
return t.CreatePhishingPage(
domain,
email,
&campaignRecipientID,
recipient,
content,
campaignTemplate,
"stateParam",
"urlPath",
)
}
// CreateMailBody returns a rendered mail body to string
func (t *Template) CreateMailBody(
urlIdentifier string,
urlPath string,
domain *model.Domain,
campaignRecipient *model.CampaignRecipient,
email *model.Email,
apiSender *model.APISender, // can be nil
) (string, error) {
return t.CreateMailBodyWithCustomURL(
urlIdentifier,
urlPath,
domain,
campaignRecipient,
email,
apiSender,
"", // empty customURL means use template domain
)
}
// CreateMailBodyWithCustomURL returns a rendered mail body to string with optional custom campaign URL
func (t *Template) CreateMailBodyWithCustomURL(
urlIdentifier string,
urlPath string,
domain *model.Domain,
campaignRecipient *model.CampaignRecipient,
email *model.Email,
apiSender *model.APISender, // can be nil
customCampaignURL string, // if provided, overrides the default campaign URL
) (string, error) {
mailData := t.CreateMail(
domain.Name.MustGet().String(),
urlIdentifier,
urlPath,
campaignRecipient,
email,
apiSender,
)
// override campaign URL if custom one is provided
if customCampaignURL != "" {
(*mailData)["URL"] = customCampaignURL
}
mailContentTemplate := template.New("mailContent")
mailContentTemplate = mailContentTemplate.Funcs(TemplateFuncs())
content, err := email.Content.Get()
if err != nil {
t.Logger.Errorw("failed to get email content", "error", err)
return "", errs.Wrap(err)
}
mailTemplate, err := mailContentTemplate.Parse(content.String())
if err != nil {
t.Logger.Errorw("failed to parse body", "error", err)
return "", errs.Wrap(err)
}
var mailContent bytes.Buffer
if err := mailTemplate.Execute(&mailContent, mailData); err != nil {
t.Logger.Errorw("failed to execute mail template", "error", err)
return "", errs.Wrap(err)
}
(*mailData)["Content"] = mailContent.String()
var body bytes.Buffer
if err := mailContentTemplate.Execute(&body, mailData); err != nil {
t.Logger.Errorw("failed to execute body template", "error", err)
return "", errs.Wrap(err)
}
return body.String(), nil
}
// CreatePhishingPage creates a new phishing page
func (t *Template) CreatePhishingPage(
domain *database.Domain,
email *model.Email,
campaignRecipientID *uuid.UUID,
recipient *model.Recipient,
contentToRender string,
campaignTemplate *model.CampaignTemplate,
stateParam string,
urlPath string,
) (*bytes.Buffer, error) {
w := bytes.NewBuffer([]byte{})
id := campaignRecipientID.String()
baseURL := "https://" + domain.Name
if len(domain.Name) == 0 {
baseURL = ""
}
urlIdentifier := campaignTemplate.URLIdentifier.Name.MustGet()
stateIdentifier := campaignTemplate.StateIdentifier.Name.MustGet()
url := fmt.Sprintf("%s?%s=%s&%s=%s", baseURL, urlIdentifier, id, stateIdentifier, stateParam)
tmpl, err := template.New("page").
Funcs(TemplateFuncs()).
Parse(contentToRender)
if err != nil {
return w, fmt.Errorf("failed to parse page template: %s", err)
}
data := t.newTemplateDataMap(
id,
baseURL,
url,
recipient,
"", // trackingPixelPath
"", // trackingPixelMarkup
email,
nil, // apiSender
)
err = tmpl.Execute(w, data)
if err != nil {
return w, fmt.Errorf("failed to execute page template: %s", err)
}
return w, nil
}
// newTemplateDataMap creates a new data map for templates
func (t *Template) newTemplateDataMap(
id string,
baseURL string,
url string,
recipient *model.Recipient,
trackingPixelPath string,
trackingPixelMarkup string,
email *model.Email,
apiSender *model.APISender,
) *map[string]any {
recipientFirstName := ""
if v, err := recipient.FirstName.Get(); err == nil {
recipientFirstName = v.String()
}
recipientLastName := ""
if v, err := recipient.LastName.Get(); err == nil {
recipientLastName = v.String()
}
recipientEmail := ""
if v, err := recipient.Email.Get(); err == nil {
recipientEmail = v.String()
}
recipientPhone := ""
if v, err := recipient.Phone.Get(); err == nil {
recipientPhone = v.String()
}
recipientExtraIdentifier := ""
if v, err := recipient.ExtraIdentifier.Get(); err == nil {
recipientExtraIdentifier = v.String()
}
recipientPosition := ""
if v, err := recipient.Position.Get(); err == nil {
recipientPosition = v.String()
}
recipientDepartment := ""
if v, err := recipient.Department.Get(); err == nil {
recipientDepartment = v.String()
}
recipientCity := ""
if v, err := recipient.City.Get(); err == nil {
recipientCity = v.String()
}
recipientCountry := ""
if v, err := recipient.Country.Get(); err == nil {
recipientCountry = v.String()
}
recipientMisc := ""
if v, err := recipient.Misc.Get(); err == nil {
recipientMisc = v.String()
}
mailHeaderFrom := ""
if v, err := email.MailHeaderFrom.Get(); err == nil {
mailHeaderFrom = v.String()
}
m := map[string]any{
"rID": id,
"FirstName": recipientFirstName,
"LastName": recipientLastName,
"Email": recipientEmail,
"To": recipientEmail, // alias of Email
"Phone": recipientPhone,
"ExtraIdentifier": recipientExtraIdentifier,
"Position": recipientPosition,
"Department": recipientDepartment,
"City": recipientCity,
"Country": recipientCountry,
"Misc": recipientMisc,
"Tracker": trackingPixelMarkup,
"TrackingURL": trackingPixelPath,
// sender fields
"From": mailHeaderFrom,
// general fields
"BaseURL": baseURL,
"URL": url,
"APIKey": "",
"CustomField1": "",
"CustomField2": "",
"CustomField3": "",
"CustomField4": "",
}
if apiSender != nil {
m["APIKey"] = utils.NullableToString(apiSender.APIKey)
m["CustomField1"] = utils.NullableToString(apiSender.CustomField1)
m["CustomField2"] = utils.NullableToString(apiSender.CustomField2)
m["CustomField3"] = utils.NullableToString(apiSender.CustomField3)
m["CustomField4"] = utils.NullableToString(apiSender.CustomField4)
}
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
}
// handle empty or whitespace-only content
content = strings.TrimSpace(content)
if content == "" {
return content
}
// If just plain text without any HTML, append
if !strings.Contains(content, "<") {
return content + trackingPixelTemplate
}
// find the first main container tag (like div), case insensitive
startDiv := -1
lowerContent := strings.ToLower(content)
if idx := strings.Index(lowerContent, "