mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-07 12:57:54 +02:00
added delete campaign event when campaign is open
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
@@ -143,6 +143,7 @@ const (
|
||||
ROUTE_V1_CAMPAIGN_RECIPIENTS = "/api/v1/campaign/:id/recipients"
|
||||
ROUTE_V1_CAMPAIGN_RESULT_STATS = "/api/v1/campaign/:id/statistics"
|
||||
ROUTE_V1_CAMPAIGN_EVENTS = "/api/v1/campaign/:id/events"
|
||||
ROUTE_V1_CAMPAIGN_EVENT_ID = "/api/v1/campaign/event/:id"
|
||||
ROUTE_V1_CAMPAIGN_EVENT_NAMES = "/api/v1/campaign/event-types"
|
||||
ROUTE_V1_CAMPAIGN_STATS = "/api/v1/campaign/statistics"
|
||||
ROUTE_V1_CAMPAIGN_STATS_ID = "/api/v1/campaign/:id/stats"
|
||||
@@ -409,6 +410,7 @@ func setupRoutes(
|
||||
GET(ROUTE_V1_CAMPAIGN_FINISHED, middleware.SessionHandler, controllers.Campaign.GetAllFinished).
|
||||
GET(ROUTE_V1_CAMPAIGN_EVENT_NAMES, middleware.SessionHandler, controllers.Campaign.GetAllEventTypes).
|
||||
GET(ROUTE_V1_CAMPAIGN_EVENTS, middleware.SessionHandler, controllers.Campaign.GetEventsByCampaignID).
|
||||
DELETE(ROUTE_V1_CAMPAIGN_EVENT_ID, middleware.SessionHandler, controllers.Campaign.DeleteEventByID).
|
||||
GET(ROUTE_V1_CAMPAIGN_STATS, middleware.SessionHandler, controllers.Campaign.GetStats).
|
||||
GET(ROUTE_V1_CAMPAIGN_RESULT_STATS, middleware.SessionHandler, controllers.Campaign.GetResultStats).
|
||||
GET(ROUTE_V1_CAMPAIGN_STATS_ID, middleware.SessionHandler, controllers.Campaign.GetCampaignStats).
|
||||
|
||||
Vendored
+13
@@ -81,3 +81,16 @@ func IsMoreNotableCampaignRecipientEventID(currentID, newID *uuid.UUID) bool {
|
||||
|
||||
return IsMoreNotableCampaignRecipientEvent(newEventName, currentEventName)
|
||||
}
|
||||
|
||||
// GetEventPriority returns the priority for an event type id, or -1 if not found
|
||||
func GetEventPriority(eventTypeID *uuid.UUID) int {
|
||||
if eventTypeID == nil {
|
||||
return -1
|
||||
}
|
||||
eventName := EventNameByID[eventTypeID.String()]
|
||||
priority, exists := CampaignEventPriority[eventName]
|
||||
if !exists {
|
||||
return -1
|
||||
}
|
||||
return priority
|
||||
}
|
||||
|
||||
@@ -901,6 +901,31 @@ func (c *Campaign) DeleteByID(g *gin.Context) {
|
||||
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
|
||||
|
||||
@@ -1296,6 +1296,77 @@ func (r *Campaign) DeleteEventsByCampaignID(
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetEventByID gets a campaign event by its id
|
||||
func (r *Campaign) GetEventByID(
|
||||
ctx context.Context,
|
||||
eventID *uuid.UUID,
|
||||
) (*database.CampaignEvent, error) {
|
||||
var event database.CampaignEvent
|
||||
res := r.DB.WithContext(ctx).
|
||||
Where("id = ?", eventID).
|
||||
First(&event)
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
}
|
||||
return &event, nil
|
||||
}
|
||||
|
||||
// DeleteEventByID deletes a campaign event by its id
|
||||
func (r *Campaign) DeleteEventByID(
|
||||
ctx context.Context,
|
||||
eventID *uuid.UUID,
|
||||
) error {
|
||||
res := r.DB.WithContext(ctx).
|
||||
Where("id = ?", eventID).
|
||||
Delete(&database.CampaignEvent{})
|
||||
return res.Error
|
||||
}
|
||||
|
||||
// GetMostNotableEventForRecipient finds the most notable event for a recipient in a campaign
|
||||
// excludes the specified event id (the one being deleted)
|
||||
func (r *Campaign) GetMostNotableEventForRecipient(
|
||||
ctx context.Context,
|
||||
campaignID *uuid.UUID,
|
||||
recipientID *uuid.UUID,
|
||||
excludeEventID *uuid.UUID,
|
||||
) (*uuid.UUID, error) {
|
||||
var events []database.CampaignEvent
|
||||
query := r.DB.WithContext(ctx).
|
||||
Where("campaign_id = ?", campaignID).
|
||||
Where("id != ?", excludeEventID)
|
||||
|
||||
// handle both regular and anonymized recipients
|
||||
if recipientID != nil {
|
||||
query = query.Where("recipient_id = ?", recipientID)
|
||||
}
|
||||
|
||||
res := query.Find(&events)
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
}
|
||||
|
||||
if len(events) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// find the most notable event using priority
|
||||
var mostNotableEventID *uuid.UUID
|
||||
highestPriority := -1
|
||||
|
||||
for _, event := range events {
|
||||
if event.EventID == nil {
|
||||
continue
|
||||
}
|
||||
priority := cache.GetEventPriority(event.EventID)
|
||||
if priority > highestPriority {
|
||||
highestPriority = priority
|
||||
mostNotableEventID = event.EventID
|
||||
}
|
||||
}
|
||||
|
||||
return mostNotableEventID, nil
|
||||
}
|
||||
|
||||
// AddAnonymizedAt adds an anonymized at time to a campaign
|
||||
func (r *Campaign) AddAnonymizedAt(
|
||||
ctx context.Context,
|
||||
|
||||
@@ -418,6 +418,30 @@ func (c *CampaignRecipient) UpdateByID(
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateNotableEventByID updates only the notable event id for a campaign recipient
|
||||
func (c *CampaignRecipient) UpdateNotableEventByID(
|
||||
ctx context.Context,
|
||||
campaignRecipientID *uuid.UUID,
|
||||
notableEventTypeID *uuid.UUID,
|
||||
) error {
|
||||
if campaignRecipientID == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
row := map[string]any{
|
||||
"notable_event_id": notableEventTypeID,
|
||||
}
|
||||
AddUpdatedAt(row)
|
||||
|
||||
res := c.DB.
|
||||
WithContext(ctx).
|
||||
Model(&database.CampaignRecipient{}).
|
||||
Where("id = ?", campaignRecipientID).
|
||||
Updates(row)
|
||||
|
||||
return res.Error
|
||||
}
|
||||
|
||||
// Anonymize adds an anonymized id to a campaign recipient
|
||||
func (r *CampaignRecipient) Anonymize(
|
||||
ctx context.Context,
|
||||
|
||||
@@ -1621,6 +1621,120 @@ func (c *Campaign) DeleteByID(
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteEventByID deletes a single campaign event by its ID
|
||||
// only allows deletion on open campaigns (not closed)
|
||||
// recalculates the notable event if the deleted event was the notable one
|
||||
func (c *Campaign) DeleteEventByID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
eventID *uuid.UUID,
|
||||
) error {
|
||||
ae := NewAuditEvent("Campaign.DeleteEventByID", session)
|
||||
ae.Details["eventID"] = eventID.String()
|
||||
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
c.LogAuthError(err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
c.AuditLogNotAuthorized(ae)
|
||||
return errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
// get the event to be deleted
|
||||
event, err := c.CampaignRepository.GetEventByID(ctx, eventID)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to get campaign event by id", "error", err, "eventID", eventID.String())
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
ae.Details["campaignID"] = event.CampaignID.String()
|
||||
if event.RecipientID != nil {
|
||||
ae.Details["recipientID"] = event.RecipientID.String()
|
||||
}
|
||||
|
||||
// get the campaign to check if it's closed
|
||||
campaign, err := c.CampaignRepository.GetByID(ctx, event.CampaignID, &repository.CampaignOption{})
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to get campaign", "error", err, "campaignID", event.CampaignID.String())
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// check if campaign is closed
|
||||
if campaign.ClosedAt.IsSpecified() && !campaign.ClosedAt.IsNull() {
|
||||
c.Logger.Debugw("cannot delete event from closed campaign",
|
||||
"campaignID", event.CampaignID.String(),
|
||||
"eventID", eventID.String(),
|
||||
)
|
||||
return errs.ErrCampaignAlreadyClosed
|
||||
}
|
||||
|
||||
// get the campaign recipient to recalculate notable event after deletion
|
||||
var campaignRecipient *model.CampaignRecipient
|
||||
|
||||
if event.RecipientID != nil {
|
||||
campaignRecipient, err = c.CampaignRecipientRepository.GetByCampaignAndRecipientID(
|
||||
ctx,
|
||||
event.CampaignID,
|
||||
event.RecipientID,
|
||||
&repository.CampaignRecipientOption{},
|
||||
)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to get campaign recipient",
|
||||
"error", err,
|
||||
"campaignID", event.CampaignID.String(),
|
||||
"recipientID", event.RecipientID.String(),
|
||||
)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
}
|
||||
|
||||
// delete the event
|
||||
err = c.CampaignRepository.DeleteEventByID(ctx, eventID)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to delete campaign event", "error", err, "eventID", eventID.String())
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// recalculate notable event after deleting an event for this recipient
|
||||
if campaignRecipient != nil {
|
||||
campaignRecipientID, err := campaignRecipient.ID.Get()
|
||||
if err != nil {
|
||||
c.Logger.Errorw("campaign recipient has no ID, skipping notable event update",
|
||||
"error", err,
|
||||
"campaignID", event.CampaignID.String(),
|
||||
)
|
||||
} else {
|
||||
mostNotableEventTypeID, err := c.CampaignRepository.GetMostNotableEventForRecipient(
|
||||
ctx,
|
||||
event.CampaignID,
|
||||
event.RecipientID,
|
||||
eventID,
|
||||
)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to get most notable event for recipient",
|
||||
"error", err,
|
||||
"campaignID", event.CampaignID.String(),
|
||||
"recipientID", event.RecipientID.String(),
|
||||
)
|
||||
} else {
|
||||
err = c.CampaignRecipientRepository.UpdateNotableEventByID(ctx, &campaignRecipientID, mostNotableEventTypeID)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to update campaign recipient notable event",
|
||||
"error", err,
|
||||
"campaignRecipientID", campaignRecipientID.String(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.AuditLogAuthorized(ae)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendNextBatch sends the next batch of emails
|
||||
// atm this is only audit logged on auth failures
|
||||
func (c *Campaign) SendNextBatch(
|
||||
|
||||
@@ -882,6 +882,16 @@ export class API {
|
||||
return await deleteJSON(this.getPath(`/campaign/${id}`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a campaign event.
|
||||
*
|
||||
* @param {string} eventID
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
deleteEvent: async (eventID) => {
|
||||
return await deleteJSON(this.getPath(`/campaign/event/${eventID}`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get campaign statistics by campaign ID.
|
||||
*
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
let processedEvents = [];
|
||||
let filteredEventsCache = null;
|
||||
let lastFilterHash = '';
|
||||
let lastEventsRef = null;
|
||||
let lastEventsHash = '';
|
||||
|
||||
// animation and timing handles
|
||||
let rafId = null;
|
||||
@@ -534,16 +534,15 @@
|
||||
|
||||
xScale = d3.scaleTime().domain([domainStart, domainEnd]).range([0, width]);
|
||||
|
||||
renderCircles(xScale);
|
||||
updateAxesImmediate(xScale);
|
||||
// use transformed scale if available, otherwise base scale
|
||||
const activeScale = currentTransform ? currentTransform.rescaleX(xScale) : xScale;
|
||||
|
||||
renderCircles(activeScale);
|
||||
updateAxesImmediate(activeScale);
|
||||
updateCenterDate(xScale);
|
||||
updateCurrentTimeIndicatorImmediate();
|
||||
|
||||
if (currentTransform) {
|
||||
const newX = currentTransform.rescaleX(xScale);
|
||||
updateAxesImmediate(newX);
|
||||
updateCirclePositionsFast(newX);
|
||||
} else {
|
||||
if (!currentTransform) {
|
||||
const optimalScale = calculateOptimalScale(domainStart, domainEnd);
|
||||
const initialTranslate = (width - width * optimalScale) / 2;
|
||||
currentTransform = d3.zoomIdentity.translate(initialTranslate, 0).scale(optimalScale);
|
||||
@@ -810,8 +809,11 @@
|
||||
});
|
||||
|
||||
// reactive: process events when they change
|
||||
$: if (events !== lastEventsRef) {
|
||||
lastEventsRef = events;
|
||||
// use content-based hash to detect changes (not reference equality)
|
||||
// include all event ids to detect deletions from any position
|
||||
$: eventsHash = events?.map((e) => e.id).join(',') || '';
|
||||
$: if (eventsHash !== lastEventsHash) {
|
||||
lastEventsHash = eventsHash;
|
||||
processedEvents = processEvents(events);
|
||||
filteredEventsCache = null;
|
||||
circleNodes = [];
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
<script>
|
||||
import { toEvent } from '$lib/utils/events';
|
||||
import { onMount } from 'svelte';
|
||||
export let eventName;
|
||||
|
||||
let event = { name: '', priority: null, color: null };
|
||||
|
||||
onMount(() => {
|
||||
event = toEvent(eventName);
|
||||
});
|
||||
// reactive statement to update event when eventName prop changes
|
||||
$: event = toEvent(eventName);
|
||||
</script>
|
||||
|
||||
<div
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
import FormGrid from '$lib/components/FormGrid.svelte';
|
||||
import FormColumns from '$lib/components/FormColumns.svelte';
|
||||
import FormColumn from '$lib/components/FormColumn.svelte';
|
||||
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
|
||||
import TableDeleteButton from '$lib/components/table/TableDeleteButton2.svelte';
|
||||
import EventName from '$lib/components/table/EventName.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { globalButtonDisabledAttributes } from '$lib/utils/form';
|
||||
@@ -147,6 +149,12 @@
|
||||
let reportedDateColumn = '';
|
||||
let pendingEmailPreviewRecipient = null;
|
||||
let storedCookieData = '';
|
||||
let isDeleteEventAlertVisible = false;
|
||||
let deleteEventValues = {
|
||||
id: null,
|
||||
name: null,
|
||||
eventName: null
|
||||
};
|
||||
|
||||
// cleanup when modal is cancelled
|
||||
$: if (!isTrackingPixelWarningVisible && pendingEmailPreviewRecipient) {
|
||||
@@ -657,6 +665,42 @@
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteEventAlert = (event) => {
|
||||
isDeleteEventAlertVisible = true;
|
||||
deleteEventValues.id = event.id;
|
||||
deleteEventValues.name = event.recipient?.email || 'anonymized';
|
||||
const systemEventName = campaign.eventTypesIDToNameMap[event.eventID] || '';
|
||||
deleteEventValues.eventName = toEvent(systemEventName).name || 'Unknown Event';
|
||||
};
|
||||
|
||||
const onClickDeleteEvent = async (eventID) => {
|
||||
try {
|
||||
const res = await api.campaign.deleteEvent(eventID);
|
||||
if (res.success) {
|
||||
addToast('Event deleted', 'Success');
|
||||
// refresh events, stats, and recipients table (for notable event update)
|
||||
await getEvents();
|
||||
await setResults();
|
||||
await refreshRecipientsTimes();
|
||||
await refreshCampaignRecipients();
|
||||
// reset timeline and refetch all events (timeline only fetches new events incrementally)
|
||||
timelineEvents = [];
|
||||
lastPoll3399Nano = '';
|
||||
await tick();
|
||||
await refreshCampaignEventsSince();
|
||||
// refresh recipient events modal if open
|
||||
if (isEventsModalVisible && recipientEventsRecipient.id) {
|
||||
await setRecipientEvents(recipientEventsRecipient.id);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
} catch (e) {
|
||||
addToast('Failed to delete event', 'Error');
|
||||
console.error('failed to delete campaign event:', e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const onConfirmCloseCampaign = async (a) => {
|
||||
let res;
|
||||
try {
|
||||
@@ -774,7 +818,8 @@
|
||||
if (!res.success) {
|
||||
throw res.error;
|
||||
}
|
||||
campaignRecipients = res.data?.rows ?? [];
|
||||
// force new array reference to trigger svelte reactivity
|
||||
campaignRecipients = [...(res.data?.rows ?? [])];
|
||||
campaignRecipientsHasNextPage = res.data?.hasNextPage ?? false;
|
||||
} catch (e) {
|
||||
addToast('Failed to load recipients', 'Error');
|
||||
@@ -1636,7 +1681,6 @@
|
||||
plural="events"
|
||||
hasData={!!campaign.events.length}
|
||||
hasNextPage={campaignEventsHasNextPage}
|
||||
hasActions={false}
|
||||
isGhost={isEventTableLoading}
|
||||
>
|
||||
{#each campaign.events as event (event.id)}
|
||||
@@ -1694,6 +1738,20 @@
|
||||
<TableCell>
|
||||
<CellCopy text={event.metadata || ''} />
|
||||
</TableCell>
|
||||
<TableCellEmpty />
|
||||
<TableCellAction>
|
||||
<TableDropDownEllipsis>
|
||||
<TableDeleteButton
|
||||
on:click={() => openDeleteEventAlert(event)}
|
||||
disabled={!!campaign.closedAt || !!campaign.anonymizedAt}
|
||||
title={campaign.closedAt
|
||||
? 'Cannot delete events from closed campaign'
|
||||
: campaign.anonymizedAt
|
||||
? 'Cannot delete events from anonymized campaign'
|
||||
: 'Delete event'}
|
||||
/>
|
||||
</TableDropDownEllipsis>
|
||||
</TableCellAction>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</Table>
|
||||
@@ -1836,7 +1894,6 @@
|
||||
pagination={recipientEventsTableParams}
|
||||
plural="events"
|
||||
hasData={!!recipientEvents.length}
|
||||
hasActions={false}
|
||||
>
|
||||
{#each recipientEvents as event}
|
||||
<TableRow>
|
||||
@@ -1872,6 +1929,20 @@
|
||||
<TableCell>
|
||||
<CellCopy text={event.metadata || ''} />
|
||||
</TableCell>
|
||||
<TableCellEmpty />
|
||||
<TableCellAction>
|
||||
<TableDropDownEllipsis>
|
||||
<TableDeleteButton
|
||||
on:click={() => openDeleteEventAlert(event)}
|
||||
disabled={!!campaign.closedAt || !!campaign.anonymizedAt}
|
||||
title={campaign.closedAt
|
||||
? 'Cannot delete events from closed campaign'
|
||||
: campaign.anonymizedAt
|
||||
? 'Cannot delete events from anonymized campaign'
|
||||
: 'Delete event'}
|
||||
/>
|
||||
</TableDropDownEllipsis>
|
||||
</TableCellAction>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</Table>
|
||||
@@ -2377,4 +2448,9 @@
|
||||
<FormFooter closeModal={closeReportedCSVModal} isSubmitting={isReportedCSVSubmitting} />
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
<DeleteAlert
|
||||
name={`${deleteEventValues.eventName} event for ${deleteEventValues.name}`}
|
||||
onClick={() => onClickDeleteEvent(deleteEventValues.id)}
|
||||
bind:isVisible={isDeleteEventAlertVisible}
|
||||
/>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user