From 170f92aa7285d360b45b45f233f153ac6fb0f23f Mon Sep 17 00:00:00 2001
From: Ronni Skansing
Date: Thu, 4 Dec 2025 11:23:52 +0100
Subject: [PATCH 1/6] added status modal after import recipients
Signed-off-by: Ronni Skansing
---
backend/controller/recipient.go | 4 +-
backend/controller/recipientGroup.go | 4 +-
backend/service/recipient.go | 146 ++++++++--
backend/service/recipientGroup.go | 20 +-
frontend/src/routes/recipient/+page.svelte | 255 ++++++++++++++++-
.../routes/recipient/group/[id]/+page.svelte | 256 +++++++++++++++++-
6 files changed, 647 insertions(+), 38 deletions(-)
diff --git a/backend/controller/recipient.go b/backend/controller/recipient.go
index 6029197..67d2add 100644
--- a/backend/controller/recipient.go
+++ b/backend/controller/recipient.go
@@ -490,7 +490,7 @@ func (r *Recipient) Import(g *gin.Context) {
if !req.IgnoreOverwriteEmptyFields.IsSpecified() || req.IgnoreOverwriteEmptyFields.IsNull() {
req.IgnoreOverwriteEmptyFields = nullable.NewNullableWithValue(true)
}
- _, err := r.RecipientService.Import(
+ result, err := r.RecipientService.Import(
g,
session,
req.Recipients,
@@ -500,7 +500,7 @@ func (r *Recipient) Import(g *gin.Context) {
if ok := r.handleErrors(g, err); !ok {
return
}
- r.Response.OK(g, &gin.H{})
+ r.Response.OK(g, result)
}
// DeleteByID deletes a recipient by id
diff --git a/backend/controller/recipientGroup.go b/backend/controller/recipientGroup.go
index e007858..1b24212 100644
--- a/backend/controller/recipientGroup.go
+++ b/backend/controller/recipientGroup.go
@@ -221,7 +221,7 @@ func (r *RecipientGroup) Import(g *gin.Context) {
req.IgnoreOverwriteEmptyFields = nullable.NewNullableWithValue(true)
}
- err := r.RecipientGroupService.Import(
+ result, err := r.RecipientGroupService.Import(
g,
session,
req.Recipients,
@@ -232,7 +232,7 @@ func (r *RecipientGroup) Import(g *gin.Context) {
if ok := r.handleErrors(g, err); !ok {
return
}
- r.Response.OK(g, &gin.H{})
+ r.Response.OK(g, result)
}
// AddRecipients adds recipients to a recipient group
diff --git a/backend/service/recipient.go b/backend/service/recipient.go
index 9b07518..0db3b6e 100644
--- a/backend/service/recipient.go
+++ b/backend/service/recipient.go
@@ -5,6 +5,7 @@ import (
"fmt"
"github.com/go-errors/errors"
+ "github.com/oapi-codegen/nullable"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/data"
@@ -495,37 +496,84 @@ func (r *Recipient) GetByEmail(
// Import imports recipients
// if the recipient does not exists, it will be created and added to the group
// if the recipient exits, it will be updated and added to the group
+
+// RecipientImportResult contains the results of importing recipients
+type RecipientImportResult struct {
+ SuccessIDs []*uuid.UUID `json:"successIDs"`
+ CreatedRecipients []RecipientImportSuccess `json:"createdRecipients"`
+ UpdatedRecipients []RecipientImportSuccess `json:"updatedRecipients"`
+ Failures []RecipientImportFailure `json:"failures"`
+ Summary RecipientImportSummary `json:"summary"`
+}
+
+// RecipientImportSuccess contains information about a successful import
+type RecipientImportSuccess struct {
+ Email string `json:"email"`
+ FirstName string `json:"firstName"`
+ LastName string `json:"lastName"`
+ Index int `json:"index"`
+}
+
+// RecipientImportFailure contains information about a failed import
+type RecipientImportFailure struct {
+ Email string `json:"email"`
+ Index int `json:"index"`
+ Reason string `json:"reason"`
+}
+
+// RecipientImportSummary contains summary statistics
+type RecipientImportSummary struct {
+ Total int `json:"total"`
+ Success int `json:"success"`
+ Failed int `json:"failed"`
+ Created int `json:"created"`
+ Updated int `json:"updated"`
+}
+
func (r *Recipient) Import(
ctx context.Context,
session *model.Session,
recipients []*model.Recipient,
ignoreOverwriteEmptyFields bool,
companyID *uuid.UUID,
-) ([]*uuid.UUID, error) {
+) (*RecipientImportResult, error) {
ae := NewAuditEvent("Recipient.Import", session)
- recipientsIDs := []*uuid.UUID{}
+ result := &RecipientImportResult{
+ SuccessIDs: []*uuid.UUID{},
+ CreatedRecipients: []RecipientImportSuccess{},
+ UpdatedRecipients: []RecipientImportSuccess{},
+ Failures: []RecipientImportFailure{},
+ Summary: RecipientImportSummary{
+ Total: len(recipients),
+ },
+ }
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
r.LogAuthError(err)
- return recipientsIDs, errs.Wrap(err)
+ return result, errs.Wrap(err)
}
if !isAuthorized {
r.AuditLogNotAuthorized(ae)
- return recipientsIDs, errs.ErrAuthorizationFailed
+ return result, errs.ErrAuthorizationFailed
}
if len(recipients) == 0 {
- return recipientsIDs, validate.WrapErrorWithField(errors.New("no recipients"), "add recipients")
+ return result, validate.WrapErrorWithField(errors.New("no recipients"), "add recipients")
}
- // first validate all the entries
- for _, recipient := range recipients {
- if err := recipient.Validate(); err != nil {
- return recipientsIDs, errs.Wrap(err)
+
+ // process each recipient individually, collecting successes and failures
+ for i, incoming := range recipients {
+ // validate the recipient
+ if err := incoming.Validate(); err != nil {
+ result.Failures = append(result.Failures, RecipientImportFailure{
+ Email: getEmailFromRecipient(incoming),
+ Index: i,
+ Reason: err.Error(),
+ })
+ result.Summary.Failed++
+ continue
}
- }
- // if the recipient does not exist, create it
- // if the recipient exists, update it
- for _, incoming := range recipients {
+
// check if the recipient exists
email := incoming.Email.MustGet()
current, err := r.RecipientRepository.GetByEmail(
@@ -534,8 +582,17 @@ func (r *Recipient) Import(
"id", "email", "company_id",
)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
- r.Logger.Debugw("failed to import recipients - failed to get recipient", "error", err)
- return recipientsIDs, errs.Wrap(err)
+ r.Logger.Debugw("failed to import recipient - failed to get recipient",
+ "error", err,
+ "email", email.String(),
+ )
+ result.Failures = append(result.Failures, RecipientImportFailure{
+ Email: email.String(),
+ Index: i,
+ Reason: "database error: " + err.Error(),
+ })
+ result.Summary.Failed++
+ continue
}
if current == nil {
// create recipient
@@ -548,12 +605,27 @@ func (r *Recipient) Import(
incoming,
)
if err != nil {
- r.Logger.Debugw("failed to import recipients - failed to create recipient",
+ r.Logger.Debugw("failed to import recipient - failed to create recipient",
"error", err,
+ "email", email.String(),
)
- return recipientsIDs, errs.Wrap(err)
+ result.Failures = append(result.Failures, RecipientImportFailure{
+ Email: email.String(),
+ Index: i,
+ Reason: "create failed: " + err.Error(),
+ })
+ result.Summary.Failed++
+ continue
}
- recipientsIDs = append(recipientsIDs, recipientID)
+ result.SuccessIDs = append(result.SuccessIDs, recipientID)
+ result.Summary.Success++
+ result.Summary.Created++
+ result.CreatedRecipients = append(result.CreatedRecipients, RecipientImportSuccess{
+ Email: email.String(),
+ FirstName: getStringFromOptional(incoming.FirstName),
+ LastName: getStringFromOptional(incoming.LastName),
+ Index: i,
+ })
} else {
// set the companyID to NOT SET, so it is not overwritten if supplied
incoming.CompanyID.SetUnspecified()
@@ -571,17 +643,47 @@ func (r *Recipient) Import(
incoming,
)
if err != nil {
- r.Logger.Debugw("failed to import recipients - failed to update recipient",
+ r.Logger.Debugw("failed to import recipient - failed to update recipient",
"error", err,
+ "email", email.String(),
)
- return recipientsIDs, errs.Wrap(err)
+ result.Failures = append(result.Failures, RecipientImportFailure{
+ Email: email.String(),
+ Index: i,
+ Reason: "update failed: " + err.Error(),
+ })
+ result.Summary.Failed++
+ continue
}
- recipientsIDs = append(recipientsIDs, &recipientID)
+ result.SuccessIDs = append(result.SuccessIDs, &recipientID)
+ result.Summary.Success++
+ result.Summary.Updated++
+ result.UpdatedRecipients = append(result.UpdatedRecipients, RecipientImportSuccess{
+ Email: email.String(),
+ FirstName: getStringFromOptional(incoming.FirstName),
+ LastName: getStringFromOptional(incoming.LastName),
+ Index: i,
+ })
}
}
r.AuditLogAuthorized(ae)
+ return result, nil
+}
- return recipientsIDs, nil
+// getEmailFromRecipient safely extracts email from recipient for error reporting
+func getEmailFromRecipient(r *model.Recipient) string {
+ if r.Email.IsSpecified() && !r.Email.IsNull() {
+ return r.Email.MustGet().String()
+ }
+ return ""
+}
+
+// getStringFromOptional safely extracts string from optional field
+func getStringFromOptional(field nullable.Nullable[vo.OptionalString127]) string {
+ if field.IsSpecified() && !field.IsNull() {
+ return field.MustGet().String()
+ }
+ return ""
}
// Delete deletes a recipient
diff --git a/backend/service/recipientGroup.go b/backend/service/recipientGroup.go
index 35b9510..5b65264 100644
--- a/backend/service/recipientGroup.go
+++ b/backend/service/recipientGroup.go
@@ -92,7 +92,7 @@ func (r *RecipientGroup) Import(
ignoreOverwriteEmptyFields bool,
recipientGroupID *uuid.UUID,
companyID *uuid.UUID,
-) error {
+) (*RecipientImportResult, error) {
ae := NewAuditEvent("RecipientGroup.Import", session)
ae.Details["recipientGroupId"] = recipientGroupID.String()
if companyID != nil {
@@ -102,14 +102,14 @@ func (r *RecipientGroup) Import(
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
r.LogAuthError(err)
- return err
+ return nil, err
}
if !isAuthorized {
r.AuditLogNotAuthorized(ae)
- return errs.ErrAuthorizationFailed
+ return nil, errs.ErrAuthorizationFailed
}
if len(recipients) == 0 {
- return validate.WrapErrorWithField(errors.New("no recipients"), "add recipients")
+ return nil, validate.WrapErrorWithField(errors.New("no recipients"), "add recipients")
}
// check that the recipient group exists
_, err = r.RecipientGroupRepository.GetByID(
@@ -119,9 +119,9 @@ func (r *RecipientGroup) Import(
)
if err != nil {
r.Logger.Debugw("failed to import recipients - failed to get recipient group", "error", err)
- return err
+ return nil, err
}
- recipientIDs, err := r.RecipientService.Import(
+ result, err := r.RecipientService.Import(
ctx,
session,
recipients,
@@ -129,24 +129,24 @@ func (r *RecipientGroup) Import(
companyID,
)
if err != nil {
- return err
+ return result, err
}
// add recpients to group
err = r.AddRecipients(
ctx,
session,
recipientGroupID,
- recipientIDs,
+ result.SuccessIDs,
)
if err != nil {
r.Logger.Debugw("failed to import recipients - failed to add recipients to group",
"error", err,
)
- return err
+ return result, err
}
r.AuditLogAuthorized(ae)
- return nil
+ return result, nil
}
// GetByID returns a recipient group by ID
diff --git a/frontend/src/routes/recipient/+page.svelte b/frontend/src/routes/recipient/+page.svelte
index 16e1d6c..8488033 100644
--- a/frontend/src/routes/recipient/+page.svelte
+++ b/frontend/src/routes/recipient/+page.svelte
@@ -22,6 +22,7 @@
import { getPaginatedChunkWithParams } from '$lib/service/paginationChunk';
import CheckboxField from '$lib/components/CheckboxField.svelte';
import BigButton from '$lib/components/BigButton.svelte';
+ import Button from '$lib/components/Button.svelte';
import FormColumns from '$lib/components/FormColumns.svelte';
import FormColumn from '$lib/components/FormColumn.svelte';
import FormFooter from '$lib/components/FormFooter.svelte';
@@ -63,6 +64,8 @@
ignoreOverwriteEmptyFields: true
};
let csvSkippedRows = [];
+ let importResult = null;
+ let isImportResultModalVisible = false;
const tableImportParams = newTableParams({ sortBy: 'email' });
let selectedRecipientsImportPaginatedChunk = [];
let isImportModalVisible = false;
@@ -210,8 +213,29 @@
importModalError = res.error;
return;
}
- addToast('Recipients imported', 'Success');
+
+ // store import result for display
+ importResult = res.data;
+
+ // build summary message
+ const summary = res.data.summary;
+ let message = `Import complete: ${summary.success} succeeded (${summary.created} created, ${summary.updated} updated)`;
+ if (summary.failed > 0) {
+ message += `, ${summary.failed} failed`;
+ }
+ if (csvSkippedRows.length > 0) {
+ message += `, ${csvSkippedRows.length} skipped in CSV`;
+ }
+
+ console.log(summary);
+ addToast(
+ 'Import finished',
+ summary.failed > 0 || csvSkippedRows.length > 0 ? 'Warning' : 'Success'
+ );
+
+ // show result modal
closeImportModal();
+ isImportResultModalVisible = true;
refreshRecipients();
} catch (err) {
addToast('Failed to import recipients', 'Error');
@@ -289,6 +313,7 @@
const openImportModal = () => {
csvSkippedRows = [];
importModalError = '';
+ importResult = null;
isImportModalVisible = true;
};
@@ -685,4 +710,232 @@
onClick={() => onClickDelete(deleteValues.id)}
bind:isVisible={isDeleteAlertVisible}
>
+
+ {#if isImportResultModalVisible && importResult}
+
+
+
+
+
+
+
Recipients
+
+ - Total: {importResult.summary.total}
+ - Created: {importResult.summary.created}
+ - Updated: {importResult.summary.updated}
+ - Failed: {importResult.summary.failed}
+ {#if csvSkippedRows.length > 0}
+ - Skipped in CSV: {csvSkippedRows.length}
+ {/if}
+
+
+
+
+
+
+
+ {#if importResult.createdRecipients?.length > 0}
+
+
+
+
+ Created ({importResult.createdRecipients.length})
+
+
+
+ {#each importResult.createdRecipients as recipient}
+
+
+ {recipient.email}
+
+ {#if recipient.firstName || recipient.lastName}
+
+ {recipient.firstName || ''}
+ {recipient.lastName || ''}
+
+ {/if}
+
+ {/each}
+
+
+
+
+ {/if}
+
+ {#if importResult.updatedRecipients?.length > 0}
+
+
+
+
+ Updated ({importResult.updatedRecipients.length})
+
+
+
+ {#each importResult.updatedRecipients as recipient}
+
+
+ {recipient.email}
+
+ {#if recipient.firstName || recipient.lastName}
+
+ {recipient.firstName || ''}
+ {recipient.lastName || ''}
+
+ {/if}
+
+ {/each}
+
+
+
+
+ {/if}
+
+ {#if csvSkippedRows.length > 0}
+
+
+
+
+ Skipped in CSV ({csvSkippedRows.length})
+
+
+
+ These rows were skipped during CSV parsing (before import)
+
+
+ {#each csvSkippedRows as skip}
+
+
+ Line {skip.line}: {skip.reason}
+ {#if skip.row?.email}
+ ({skip.row.email})
+ {/if}
+
+
+ {/each}
+
+
+
+
+ {/if}
+
+ {#if importResult.failures?.length > 0}
+
+
+
+
+ Import Errors ({importResult.failures.length})
+
+
+
+ These recipients failed to import (backend errors)
+
+
+ {#each importResult.failures as err}
+
+
+ {err.email}: {err.reason}
+
+
+ {/each}
+
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ {/if}
diff --git a/frontend/src/routes/recipient/group/[id]/+page.svelte b/frontend/src/routes/recipient/group/[id]/+page.svelte
index 4f6cfa4..8b5bbc5 100644
--- a/frontend/src/routes/recipient/group/[id]/+page.svelte
+++ b/frontend/src/routes/recipient/group/[id]/+page.svelte
@@ -24,6 +24,7 @@
import { parseCSVToRecipients } from '$lib/utils/csv';
import CheckboxField from '$lib/components/CheckboxField.svelte';
import BigButton from '$lib/components/BigButton.svelte';
+ import Button from '$lib/components/Button.svelte';
import FormColumns from '$lib/components/FormColumns.svelte';
import FormColumn from '$lib/components/FormColumn.svelte';
import Table from '$lib/components/table/Table.svelte';
@@ -62,6 +63,8 @@
ignoreOverwriteEmptyFields: true
};
let csvSkippedRows = [];
+ let importResult = null;
+ let isImportResultModalVisible = false;
const tableImportParams = newTableParams({ sortBy: 'email' });
let selectedRecipientsImportPaginatedChunk = [];
let isImportModalVisible = false;
@@ -264,8 +267,29 @@
importError = res.error;
return;
}
- addToast('Recipients imported to group', 'Success');
+
+ // store import result for display
+ importResult = res.data;
+
+ // build summary message
+ const summary = res.data.summary;
+ let message = `Import complete: ${summary.success} succeeded (${summary.created} created, ${summary.updated} updated)`;
+ if (summary.failed > 0) {
+ message += `, ${summary.failed} failed`;
+ }
+ if (csvSkippedRows.length > 0) {
+ message += `, ${csvSkippedRows.length} skipped in CSV`;
+ }
+
+ console.log(summary);
+ addToast(
+ 'Import finished',
+ summary.failed > 0 || csvSkippedRows.length > 0 ? 'Warning' : 'Success'
+ );
+
+ // show result modal
closeImportModal();
+ isImportResultModalVisible = true;
refreshRecipients();
} catch (err) {
addToast('Failed to import recipients to group', 'Error');
@@ -286,6 +310,7 @@
// track skipped rows
if (result.skipped && result.skipped.length > 0) {
+ csvSkippedRows = csvSkippedRows.concat(result.skipped);
console.info(`CSV import: ${result.skipped.length} rows skipped`, result.skipped);
}
@@ -353,6 +378,7 @@
const openImportModal = () => {
csvSkippedRows = [];
importError = '';
+ importResult = null;
isImportModalVisible = true;
};
@@ -581,4 +607,232 @@
onClick={() => onClickRemoveRecipient(deleteValues.id)}
bind:isVisible={isDeleteAlertVisible}
>
+
+ {#if isImportResultModalVisible && importResult}
+
+
+
+
+
+
+
Recipients
+
+ - Total: {importResult.summary.total}
+ - Created: {importResult.summary.created}
+ - Updated: {importResult.summary.updated}
+ - Failed: {importResult.summary.failed}
+ {#if csvSkippedRows.length > 0}
+ - Skipped in CSV: {csvSkippedRows.length}
+ {/if}
+
+
+
+
+
+
+
+ {#if importResult.createdRecipients?.length > 0}
+
+
+
+
+ Created ({importResult.createdRecipients.length})
+
+
+
+ {#each importResult.createdRecipients as recipient}
+
+
+ {recipient.email}
+
+ {#if recipient.firstName || recipient.lastName}
+
+ {recipient.firstName || ''}
+ {recipient.lastName || ''}
+
+ {/if}
+
+ {/each}
+
+
+
+
+ {/if}
+
+ {#if importResult.updatedRecipients?.length > 0}
+
+
+
+
+ Updated ({importResult.updatedRecipients.length})
+
+
+
+ {#each importResult.updatedRecipients as recipient}
+
+
+ {recipient.email}
+
+ {#if recipient.firstName || recipient.lastName}
+
+ {recipient.firstName || ''}
+ {recipient.lastName || ''}
+
+ {/if}
+
+ {/each}
+
+
+
+
+ {/if}
+
+ {#if csvSkippedRows.length > 0}
+
+
+
+
+ Skipped in CSV ({csvSkippedRows.length})
+
+
+
+ These rows were skipped during CSV parsing (before import)
+
+
+ {#each csvSkippedRows as skip}
+
+
+ Line {skip.line}: {skip.reason}
+ {#if skip.row?.email}
+ ({skip.row.email})
+ {/if}
+
+
+ {/each}
+
+
+
+
+ {/if}
+
+ {#if importResult.failures?.length > 0}
+
+
+
+
+ Import Errors ({importResult.failures.length})
+
+
+
+ These recipients failed to import (backend errors)
+
+
+ {#each importResult.failures as err}
+
+
+ {err.email}: {err.reason}
+
+
+ {/each}
+
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ {/if}
From 2e9227900dca0b70ef9878c48fe7441cad438341 Mon Sep 17 00:00:00 2001
From: Ronni Skansing
Date: Thu, 4 Dec 2025 19:56:34 +0100
Subject: [PATCH 2/6] add submit capture for PUT, PATCH and more content types
Signed-off-by: Ronni Skansing
---
backend/app/server.go | 177 +++++++++++++++++++++++++++++++++++++-----
1 file changed, 158 insertions(+), 19 deletions(-)
diff --git a/backend/app/server.go b/backend/app/server.go
index 1c76a1d..9c2901b 100644
--- a/backend/app/server.go
+++ b/backend/app/server.go
@@ -4,7 +4,10 @@ import (
"bytes"
"context"
"crypto/tls"
+ "encoding/base64"
+ "encoding/json"
"fmt"
+ "io"
"log"
"mime"
"net"
@@ -967,14 +970,14 @@ func (s *Server) checkAndServePhishingPage(
nextPageType = data.PAGE_TYPE_DONE
}
}
- isPOSTRequest := c.Request.Method == http.MethodPost
- // if this is a POST request, then save the submitted data
- if isPOSTRequest {
+ // support POST, PUT, and PATCH methods for data submission
+ isDataSubmission := c.Request.Method == http.MethodPost ||
+ c.Request.Method == http.MethodPut ||
+ c.Request.Method == http.MethodPatch
+
+ // if this is a data submission request, then save the submitted data
+ if isDataSubmission {
submitDataEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA]
- err = c.Request.ParseForm()
- if err != nil {
- return true, fmt.Errorf("failed to parse submitted form data: %s", err)
- }
newEventID := uuid.New()
campaignID := campaign.ID.MustGet()
clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request))
@@ -983,20 +986,156 @@ func (s *Server) checkAndServePhishingPage(
// prepare submitted data for webhook
var webhookData map[string]interface{}
+ var rawData string
+
if campaign.SaveSubmittedData.MustGet() {
- submittedData, err = vo.NewOptionalString1MB(c.Request.PostForm.Encode())
+ // parse based on content type
+ contentType := c.Request.Header.Get("Content-Type")
+ mediaType, _, _ := mime.ParseMediaType(contentType)
+
+ switch {
+ case strings.Contains(mediaType, "application/json"):
+ // handle json content type
+ body, err := io.ReadAll(c.Request.Body)
+ if err != nil {
+ return true, fmt.Errorf("failed to read json request body: %s", err)
+ }
+ c.Request.Body.Close()
+
+ rawData = string(body)
+
+ // parse json for webhook
+ webhookData = make(map[string]interface{})
+ if err := json.Unmarshal(body, &webhookData); err != nil {
+ s.logger.Warnw("failed to parse json for webhook", "error", err)
+ // store raw data if json parsing fails
+ webhookData = map[string]interface{}{
+ "_raw": rawData,
+ }
+ }
+
+ case strings.Contains(mediaType, "multipart/form-data"):
+ // handle multipart form data
+ err = c.Request.ParseMultipartForm(32 << 20) // 32 MB max
+ if err != nil {
+ return true, fmt.Errorf("failed to parse multipart form data: %s", err)
+ }
+
+ // encode multipart data
+ if c.Request.MultipartForm != nil {
+ values := url.Values{}
+ for key, vals := range c.Request.MultipartForm.Value {
+ for _, val := range vals {
+ values.Add(key, val)
+ }
+ }
+ // include file information and content in saved data
+ for key, files := range c.Request.MultipartForm.File {
+ for i, file := range files {
+ prefix := key
+ if len(files) > 1 {
+ prefix = fmt.Sprintf("%s[%d]", key, i)
+ }
+ values.Add(prefix+"[filename]", file.Filename)
+ values.Add(prefix+"[size]", fmt.Sprintf("%d", file.Size))
+ values.Add(prefix+"[content_type]", file.Header.Get("Content-Type"))
+
+ // read and encode file content
+ f, err := file.Open()
+ if err != nil {
+ s.logger.Warnw("failed to open uploaded file", "filename", file.Filename, "error", err)
+ continue
+ }
+ fileContent, err := io.ReadAll(f)
+ f.Close()
+ if err != nil {
+ s.logger.Warnw("failed to read uploaded file", "filename", file.Filename, "error", err)
+ continue
+ }
+ // encode file content as base64
+ encodedContent := base64.StdEncoding.EncodeToString(fileContent)
+ values.Add(prefix+"[content]", encodedContent)
+ }
+ }
+ rawData = values.Encode()
+
+ // convert to map for webhook
+ webhookData = make(map[string]interface{})
+ for key, vals := range c.Request.MultipartForm.Value {
+ if len(vals) == 1 {
+ webhookData[key] = vals[0]
+ } else {
+ webhookData[key] = vals
+ }
+ }
+ // add file metadata and content for webhook
+ fileData := make(map[string]interface{})
+ for key, files := range c.Request.MultipartForm.File {
+ fileList := make([]map[string]interface{}, 0, len(files))
+ for _, file := range files {
+ fileInfo := map[string]interface{}{
+ "filename": file.Filename,
+ "size": file.Size,
+ }
+ if contentType := file.Header.Get("Content-Type"); contentType != "" {
+ fileInfo["content_type"] = contentType
+ }
+
+ // read and encode file content
+ f, err := file.Open()
+ if err != nil {
+ s.logger.Warnw("failed to open uploaded file for webhook", "filename", file.Filename, "error", err)
+ fileList = append(fileList, fileInfo)
+ continue
+ }
+ fileContent, err := io.ReadAll(f)
+ f.Close()
+ if err != nil {
+ s.logger.Warnw("failed to read uploaded file for webhook", "filename", file.Filename, "error", err)
+ fileList = append(fileList, fileInfo)
+ continue
+ }
+ // encode file content as base64
+ fileInfo["content"] = base64.StdEncoding.EncodeToString(fileContent)
+ fileInfo["encoding"] = "base64"
+
+ fileList = append(fileList, fileInfo)
+ }
+ if len(fileList) == 1 {
+ fileData[key] = fileList[0]
+ } else {
+ fileData[key] = fileList
+ }
+ }
+ if len(fileData) > 0 {
+ webhookData["_files"] = fileData
+ }
+ }
+
+ default:
+ // handle url-encoded and other form data
+ err = c.Request.ParseForm()
+ if err != nil {
+ return true, fmt.Errorf("failed to parse submitted form data: %s", err)
+ }
+
+ rawData = c.Request.PostForm.Encode()
+
+ // convert form data to map for webhook
+ webhookData = make(map[string]interface{})
+ for key, values := range c.Request.PostForm {
+ if len(values) == 1 {
+ webhookData[key] = values[0]
+ } else {
+ webhookData[key] = values
+ }
+ }
+ }
+
+ submittedData, err = vo.NewOptionalString1MB(rawData)
if err != nil {
return true, fmt.Errorf("user submitted phishing data too large: %s", err)
}
- // convert form data to map for webhook
- webhookData = make(map[string]interface{})
- for key, values := range c.Request.PostForm {
- if len(values) == 1 {
- webhookData[key] = values[0]
- } else {
- webhookData[key] = values
- }
- }
}
var event *model.CampaignEvent
// only save data if red team flag is set
@@ -1078,8 +1217,8 @@ func (s *Server) checkAndServePhishingPage(
}
}
}
- // if redirect && POST && final page
- if isPOSTRequest {
+ // if redirect && data submission && final page
+ if isDataSubmission {
if redirectURL, err := cTemplate.AfterLandingPageRedirectURL.Get(); err == nil {
if v := redirectURL.String(); len(v) > 0 {
// if the current page is landing and there is no after, redirect
From 071b01ac49c12b0229bf55df199c92b910e4f2c8 Mon Sep 17 00:00:00 2001
From: Ronni Skansing
Date: Fri, 5 Dec 2025 22:59:14 +0100
Subject: [PATCH 3/6] improve config error types
Signed-off-by: Ronni Skansing
---
backend/service/proxy.go | 26 +++++++++++++-------------
1 file changed, 13 insertions(+), 13 deletions(-)
diff --git a/backend/service/proxy.go b/backend/service/proxy.go
index 26c09d3..be992fc 100644
--- a/backend/service/proxy.go
+++ b/backend/service/proxy.go
@@ -2092,13 +2092,13 @@ func (m *Proxy) validatePhishingDomainUniquenessForUpdate(ctx context.Context, p
func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session, proxyID *uuid.UUID, proxy *model.Proxy) error {
proxyConfig, err := proxy.ProxyConfig.Get()
if err != nil {
- return fmt.Errorf("failed to get proxy config: %w", err)
+ return errs.NewCustomError(fmt.Errorf("failed to get proxy config: %w", err))
}
// parse complete YAML structure
var config ProxyServiceConfigYAML
if err := yaml.Unmarshal([]byte(proxyConfig.String()), &config); err != nil {
- return fmt.Errorf("failed to parse proxy config YAML: %w", err)
+ return errs.NewCustomError(fmt.Errorf("failed to parse proxy config YAML: %w", err))
}
// set default values
@@ -2125,7 +2125,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session,
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
- return fmt.Errorf("'to' field is required for domain mapping '%s'", originalDomain)
+ return errs.NewCustomError(fmt.Errorf("'to' field is required for domain mapping '%s'", originalDomain))
}
// check if domain already exists (might be from previous failed attempt)
@@ -2138,7 +2138,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session,
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
- return fmt.Errorf("invalid phishing domain format %s: %w", domainConfig.To, err)
+ return errs.NewCustomError(fmt.Errorf("invalid phishing domain format %s: %w", domainConfig.To, err))
}
existingDomain, err := m.DomainRepository.GetByName(ctx, phishingDomainVO, &repository.DomainOption{})
@@ -2164,7 +2164,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session,
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
- return fmt.Errorf("domain %s already exists and is incompatible", domainConfig.To)
+ return errs.NewCustomError(fmt.Errorf("domain %s already exists and is incompatible", domainConfig.To))
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
// database error
m.Logger.Errorw("failed to check existing domain",
@@ -2194,7 +2194,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session,
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
- return fmt.Errorf("failed to create proxy target domain for %s: %w", domainConfig.To, err)
+ return errs.NewCustomError(fmt.Errorf("failed to create proxy target domain for %s: %w", domainConfig.To, err))
}
domain.ProxyTargetDomain.Set(*proxyTargetDomain)
@@ -2227,7 +2227,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session,
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
- return fmt.Errorf("failed to create page content for %s: %w", domainConfig.To, err)
+ return errs.NewCustomError(fmt.Errorf("failed to create page content for %s: %w", domainConfig.To, err))
}
domain.PageContent.Set(*pageContent)
@@ -2240,7 +2240,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session,
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
- return fmt.Errorf("failed to create page not found content for %s: %w", domainConfig.To, err)
+ return errs.NewCustomError(fmt.Errorf("failed to create page not found content for %s: %w", domainConfig.To, err))
}
domain.PageNotFoundContent.Set(*pageNotFoundContent)
@@ -2253,7 +2253,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session,
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
- return fmt.Errorf("failed to create redirect URL for %s: %w", domainConfig.To, err)
+ return errs.NewCustomError(fmt.Errorf("failed to create redirect URL for %s: %w", domainConfig.To, err))
}
domain.RedirectURL.Set(*redirectURL)
@@ -2270,7 +2270,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session,
)
// rollback created domains on error
m.rollbackCreatedDomains(ctx, session, createdDomains)
- return fmt.Errorf("failed to create domain %s: %w", domainConfig.To, err)
+ return errs.NewCustomError(fmt.Errorf("failed to create domain %s: %w", domainConfig.To, err))
}
createdDomains = append(createdDomains, domainConfig.To)
@@ -2333,7 +2333,7 @@ func (m *Proxy) rollbackCreatedDomains(ctx context.Context, session *model.Sessi
func (m *Proxy) syncProxyDomains(ctx context.Context, session *model.Session, proxyID *uuid.UUID, proxy *model.Proxy) error {
proxyConfig, err := proxy.ProxyConfig.Get()
if err != nil {
- return fmt.Errorf("failed to get proxy config for sync: %w", err)
+ return errs.NewCustomError(fmt.Errorf("failed to get proxy config for sync: %w", err))
}
// get current proxy domains by proxy ID
@@ -2365,7 +2365,7 @@ func (m *Proxy) syncProxyDomains(ctx context.Context, session *model.Session, pr
// parse complete YAML structure
var config ProxyServiceConfigYAML
if err := yaml.Unmarshal([]byte(proxyConfig.String()), &config); err != nil {
- return fmt.Errorf("failed to parse proxy config YAML for sync: %w", err)
+ return errs.NewCustomError(fmt.Errorf("failed to parse proxy config YAML for sync: %w", err))
}
// set default values
@@ -2628,7 +2628,7 @@ func (m *Proxy) syncProxyDomains(ctx context.Context, session *model.Session, pr
if errorCount > 0 {
errorDetails := strings.Join(syncErrors, "; ")
- return fmt.Errorf("proxy domain sync failed with %d errors: %s", errorCount, errorDetails)
+ return errs.NewCustomError(fmt.Errorf("proxy domain sync failed with %d errors: %s", errorCount, errorDetails))
}
return nil
From acd840285965ce215f4485420d52c7959a2b6ca4 Mon Sep 17 00:00:00 2001
From: Ronni Skansing
Date: Fri, 12 Dec 2025 13:52:47 +0100
Subject: [PATCH 4/6] rename whitebox/blackbox to simuation/red team
Signed-off-by: Ronni Skansing
---
README.md | 2 +-
frontend/src/routes/install/+page.svelte | 12 +++---------
frontend/src/routes/settings/+page.svelte | 14 ++++----------
3 files changed, 8 insertions(+), 20 deletions(-)
diff --git a/README.md b/README.md
index b55702c..f1037e3 100644
--- a/README.md
+++ b/README.md
@@ -60,7 +60,7 @@ See [production docker compose example](https://github.com/phishingclub/phishing
### Blogs & Resources
- [Covert red team phishing with Phishing Club by Phishing Club](http://phishing.club/blog/covert-red-team-phishing-with-phishing-club/)
-- [Whitebox vs blackbox phishing by Phishing Club](http://phishing.club/blog/white-box-vs-black-box-phishing/)
+- [Phishing Simulation vs Red Team Phishing: Understanding Different Approaches](https://phishing.club/blog/phishing-simulation-vs-red-team-phishing/)
### Students & Learning
diff --git a/frontend/src/routes/install/+page.svelte b/frontend/src/routes/install/+page.svelte
index 3dbff13..f1420b8 100644
--- a/frontend/src/routes/install/+page.svelte
+++ b/frontend/src/routes/install/+page.svelte
@@ -320,9 +320,6 @@
/>
- Whitebox
-
-
Phishing Simulation
@@ -341,10 +338,7 @@
/>
- Blackbox
-
-
- Red Team Phishing.
+ Red Team Phishing
@@ -354,8 +348,8 @@
Read about the difference between
whitebox and blackbox phishingphishing simulation and red team phishing
diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte
index b1d4914..c8e635a 100644
--- a/frontend/src/routes/settings/+page.svelte
+++ b/frontend/src/routes/settings/+page.svelte
@@ -726,10 +726,7 @@
/>
- Whitebox Mode
-
-
- For Phishing Simulation
+ Phishing Simulation
@@ -747,18 +744,15 @@
/>
- Blackbox Mode
-
-
- For Red Teaming
+ Red Team Phishing
Read about the difference between whitebox and blackbox phishingphishing simulation and red team phishing
From 31c436f51e2d760e3d746e90fb75d04b976d3901 Mon Sep 17 00:00:00 2001
From: Ronni Skansing
Date: Sat, 13 Dec 2025 16:17:52 +0100
Subject: [PATCH 5/6] added Sushi Session for captured cookie handling
Signed-off-by: Ronni Skansing
---
.../src/routes/campaign/[id]/+page.svelte | 26 +++++++++----------
1 file changed, 13 insertions(+), 13 deletions(-)
diff --git a/frontend/src/routes/campaign/[id]/+page.svelte b/frontend/src/routes/campaign/[id]/+page.svelte
index 1f67e95..25678f2 100644
--- a/frontend/src/routes/campaign/[id]/+page.svelte
+++ b/frontend/src/routes/campaign/[id]/+page.svelte
@@ -132,7 +132,7 @@
let isAnonymizeModalVisible = false;
let isSendMessageModalVisible = false;
let isSetAsSentModalVisible = false;
- let isStorageAceModalVisible = false;
+ let isSessionSushiModalVisible = false;
let storedCookieData = '';
let sendMessageRecipient = null;
let setAsSentRecipient = null;
@@ -578,13 +578,13 @@
isAnonymizeModalVisible = false;
};
- const closeStorageAceModal = () => {
- isStorageAceModalVisible = false;
+ const closeSessionSushiModal = () => {
+ isSessionSushiModalVisible = false;
storedCookieData = '';
};
- const onStorageAceModalOk = () => {
- closeStorageAceModal();
+ const onSessionSushiModalOk = () => {
+ closeSessionSushiModal();
};
/** @param {string} eventData @param {string} eventName */
@@ -596,7 +596,7 @@
if (eventName === 'campaign_recipient_submitted_data' && eventData.startsWith('🍪')) {
storedCookieData = eventData;
- isStorageAceModalVisible = true;
+ isSessionSushiModalVisible = true;
}
addToast('Copied to clipboard', 'Success');
@@ -2169,18 +2169,18 @@
Import cookie
- Cookies can be imported using the StorageAceSession Sushi extension.
@@ -2203,7 +2203,7 @@
-
+
@@ -2214,7 +2214,7 @@
>