@@ -2,6 +2,8 @@ package handler
22
33import (
44 "archive/zip"
5+ "crypto/sha256"
6+ "encoding/hex"
57 "encoding/json"
68 "fmt"
79 "io"
@@ -10,6 +12,8 @@ import (
1012 "path/filepath"
1113 "strings"
1214 "time"
15+
16+ "gopkg.in/yaml.v3"
1317)
1418
1519// DirectoryItem represents a single file or directory in the navigation tree
@@ -18,9 +22,37 @@ type DirectoryItem struct {
1822 IsDir bool `json:"isDir"`
1923}
2024
25+ // CampaignInfo represents campaign metadata
26+ type CampaignInfo struct {
27+ Name string `yaml:"name"`
28+ Path string
29+ Dir string
30+ }
31+
32+ // DuplicateError represents a duplicate campaign error
33+ type DuplicateError struct {
34+ Type string // "name" or "folder"
35+ Value string // the duplicate name or folder
36+ Campaigns []string // paths of conflicting campaigns
37+ }
38+
39+ func (e DuplicateError ) Error () string {
40+ if e .Type == "name" {
41+ return fmt .Sprintf ("Duplicate campaign name '%s' found in: %s" , e .Value , strings .Join (e .Campaigns , ", " ))
42+ }
43+ return fmt .Sprintf ("Multiple campaigns found in folder '%s': %s" , e .Value , strings .Join (e .Campaigns , ", " ))
44+ }
45+
2146// DownloadHandler creates a zip archive of a directory and sends it to the client
2247func DownloadHandler (baseDir string ) http.HandlerFunc {
2348 return func (w http.ResponseWriter , r * http.Request ) {
49+ // First validate campaigns for duplicates
50+ err := validateCampaigns (baseDir )
51+ if err != nil {
52+ http .Error (w , fmt .Sprintf (`{"error":"Campaign validation failed: %s"}` , err ), http .StatusConflict )
53+ return
54+ }
55+
2456 // Get requested path from query parameter
2557 reqPath := r .URL .Query ().Get ("path" )
2658 if reqPath == "" {
@@ -151,6 +183,13 @@ func DownloadHandler(baseDir string) http.HandlerFunc {
151183// ExportHandler creates a structured zip export with assets and templates
152184func ExportHandler (baseDir string ) http.HandlerFunc {
153185 return func (w http.ResponseWriter , r * http.Request ) {
186+ // First validate campaigns for duplicates
187+ err := validateCampaigns (baseDir )
188+ if err != nil {
189+ http .Error (w , fmt .Sprintf (`{"error":"Campaign validation failed: %s"}` , err ), http .StatusConflict )
190+ return
191+ }
192+
154193 // Create a timestamp for the zip filename
155194 timestamp := time .Now ().Format ("20060102-150405" )
156195 zipFilename := fmt .Sprintf ("export-%s.zip" , timestamp )
@@ -184,14 +223,47 @@ func ExportHandler(baseDir string) http.HandlerFunc {
184223 }
185224
186225 // Process phishing templates
187- err : = addPhishingTemplates (zipWriter , baseDir )
226+ err = addPhishingTemplates (zipWriter , baseDir )
188227 if err != nil {
189228 http .Error (w , fmt .Sprintf (`{"error":"Error processing templates: %s"}` , err ), http .StatusInternalServerError )
190229 return
191230 }
192231 }
193232}
194233
234+ // ValidateCampaignsHandler provides an endpoint to validate campaigns for duplicates
235+ func ValidateCampaignsHandler (baseDir string ) http.HandlerFunc {
236+ return func (w http.ResponseWriter , r * http.Request ) {
237+ w .Header ().Set ("Content-Type" , "application/json" )
238+
239+ err := validateCampaigns (baseDir )
240+ if err != nil {
241+ // Return conflict status with detailed error information
242+ response := map [string ]interface {}{
243+ "valid" : false ,
244+ "error" : err .Error (),
245+ }
246+
247+ if dupErr , ok := err .(DuplicateError ); ok {
248+ response ["type" ] = dupErr .Type
249+ response ["value" ] = dupErr .Value
250+ response ["campaigns" ] = dupErr .Campaigns
251+ }
252+
253+ w .WriteHeader (http .StatusConflict )
254+ json .NewEncoder (w ).Encode (response )
255+ return
256+ }
257+
258+ // No conflicts found
259+ response := map [string ]interface {}{
260+ "valid" : true ,
261+ "message" : "No campaign conflicts detected" ,
262+ }
263+ json .NewEncoder (w ).Encode (response )
264+ }
265+ }
266+
195267// addAssets adds all folders from assets/ in the zip
196268func addAssets (zipWriter * zip.Writer , assetsPath string ) error {
197269 return filepath .Walk (assetsPath , func (path string , info os.FileInfo , err error ) error {
@@ -251,8 +323,140 @@ func addAssets(zipWriter *zip.Writer, assetsPath string) error {
251323 })
252324}
253325
326+ // validateCampaigns checks for duplicate campaign names and folder conflicts
327+ func validateCampaigns (baseDir string ) error {
328+ campaigns := make ([]CampaignInfo , 0 )
329+ nameMap := make (map [string ][]string )
330+ folderMap := make (map [string ][]string )
331+
332+ // Collect all campaigns
333+ err := filepath .Walk (baseDir , func (path string , info os.FileInfo , err error ) error {
334+ if err != nil {
335+ return err
336+ }
337+
338+ // Skip if not a directory
339+ if ! info .IsDir () {
340+ return nil
341+ }
342+
343+ // Skip the assets directories
344+ if strings .Contains (path , "Assets" ) || strings .Contains (path , "assets" ) {
345+ return filepath .SkipDir
346+ }
347+
348+ // Skip private directories (client-specific content that should not be validated)
349+ relPath , err := filepath .Rel (baseDir , path )
350+ if err == nil {
351+ pathComponents := strings .Split (filepath .ToSlash (relPath ), "/" )
352+ if len (pathComponents ) > 0 && strings .ToLower (pathComponents [0 ]) == "private" {
353+ return filepath .SkipDir
354+ }
355+ }
356+
357+ // Check if this directory contains any HTML files
358+ hasHTML , err := containsHTMLFiles (path )
359+ if err != nil {
360+ return err
361+ }
362+
363+ // If this directory contains HTML files, it's a campaign directory
364+ if hasHTML {
365+ relPath , err := filepath .Rel (baseDir , path )
366+ if err != nil {
367+ return err
368+ }
369+
370+ campaign := CampaignInfo {
371+ Path : relPath ,
372+ Dir : filepath .Base (path ),
373+ }
374+
375+ // Try to read campaign name from data.yaml (top-level name field)
376+ dataYamlPath := filepath .Join (path , "data.yaml" )
377+ if _ , err := os .Stat (dataYamlPath ); err == nil {
378+ data , err := os .ReadFile (dataYamlPath )
379+ if err == nil {
380+ var yamlData struct {
381+ Name string `yaml:"name"`
382+ // Note: emails and landing_pages sections are ignored for campaign-level validation
383+ // as they can have the same names within a single campaign
384+ }
385+ if yaml .Unmarshal (data , & yamlData ) == nil && yamlData .Name != "" {
386+ campaign .Name = yamlData .Name
387+ }
388+ }
389+ }
390+
391+ // If no name in data.yaml, use directory name as campaign name
392+ if campaign .Name == "" {
393+ campaign .Name = campaign .Dir
394+ }
395+
396+ campaigns = append (campaigns , campaign )
397+
398+ // Track by campaign name (not individual email/page names)
399+ nameMap [campaign .Name ] = append (nameMap [campaign .Name ], campaign .Path )
400+
401+ // Track by folder (directory name) - prevent same folder conflicts
402+ folderMap [campaign .Dir ] = append (folderMap [campaign .Dir ], campaign .Path )
403+ }
404+
405+ return nil
406+ })
407+
408+ if err != nil {
409+ return err
410+ }
411+
412+ // Check for duplicate campaign names first
413+ for name , paths := range nameMap {
414+ if len (paths ) > 1 {
415+ // Check if this is a legitimate email/landing page organization
416+ isEmailLandingOrg := true
417+ orgTypes := make (map [string ]bool )
418+
419+ for _ , path := range paths {
420+ pathParts := strings .Split (filepath .ToSlash (path ), "/" )
421+ if len (pathParts ) >= 2 {
422+ // Check if parent directory indicates content type
423+ parentDir := strings .ToLower (pathParts [len (pathParts )- 2 ])
424+ if parentDir == "emails" || parentDir == "landing pages" || parentDir == "pages" {
425+ orgTypes [parentDir ] = true
426+ } else {
427+ isEmailLandingOrg = false
428+ break
429+ }
430+ } else {
431+ isEmailLandingOrg = false
432+ break
433+ }
434+ }
435+
436+ // If this appears to be email/landing page organization with different types, allow it
437+ if isEmailLandingOrg && len (orgTypes ) > 1 {
438+ continue // Allow this duplicate name
439+ }
440+
441+ // Otherwise, it's a real duplicate campaign name conflict
442+ return DuplicateError {
443+ Type : "name" ,
444+ Value : name ,
445+ Campaigns : paths ,
446+ }
447+ }
448+ }
449+
450+ // Note: Folder conflicts are now automatically resolved during export by adding number suffixes
451+ // No need to block export for folder name conflicts since the data.yaml determines import behavior
452+
453+ return nil
454+ }
455+
254456// addPhishingTemplates recursively finds template folders (containing *.html files) and adds them to templates/
255457func addPhishingTemplates (zipWriter * zip.Writer , baseDir string ) error {
458+ usedNames := make (map [string ]bool )
459+
256460 return filepath .Walk (baseDir , func (path string , info os.FileInfo , err error ) error {
257461 if err != nil {
258462 return err
@@ -268,6 +472,15 @@ func addPhishingTemplates(zipWriter *zip.Writer, baseDir string) error {
268472 return filepath .SkipDir
269473 }
270474
475+ // Skip private directories (client-specific content that should not be exported)
476+ relPath , err := filepath .Rel (baseDir , path )
477+ if err == nil {
478+ pathComponents := strings .Split (filepath .ToSlash (relPath ), "/" )
479+ if len (pathComponents ) > 0 && strings .ToLower (pathComponents [0 ]) == "private" {
480+ return filepath .SkipDir
481+ }
482+ }
483+
271484 // Check if this directory contains any HTML files
272485 hasHTML , err := containsHTMLFiles (path )
273486 if err != nil {
@@ -277,6 +490,18 @@ func addPhishingTemplates(zipWriter *zip.Writer, baseDir string) error {
277490 // If this directory contains HTML files, it's a template directory
278491 if hasHTML {
279492 templateName := filepath .Base (path )
493+
494+ // Handle name conflicts by adding a hash suffix
495+ if usedNames [templateName ] {
496+ // Create a unique hash based on the full path and current time
497+ hashInput := fmt .Sprintf ("%s-%d" , path , time .Now ().UnixNano ())
498+ hasher := sha256 .New ()
499+ hasher .Write ([]byte (hashInput ))
500+ hash := hex .EncodeToString (hasher .Sum (nil ))[:8 ] // Use first 8 chars
501+ templateName = fmt .Sprintf ("%s-%s" , templateName , hash )
502+ }
503+ usedNames [templateName ] = true
504+
280505 return addTemplateToZip (zipWriter , path , templateName )
281506 }
282507
0 commit comments