Skip to content

Commit 2430fd3

Browse files
committed
fix skip private folder in export and handle validate dub name and handle dub folder names
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
1 parent 3539e6d commit 2430fd3

4 files changed

Lines changed: 893 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Compatible with [Phishing Club](https://github.com/phishingclub/phishingclub) an
2020
While this tool is for editing and checking templates, sometimes you just want quick templates to import
2121
into Phishing Club. On the [Releases page](https://github.com/phishingclub/templates/releases) you can download a .zip with the templates which can be imported via the Settings page in Phishing Club.
2222

23-
Must templates require a little editing..
23+
Most templates require a little editing..
2424

2525
*Remember to change the sender on emails.*
2626

internal/handler/api.go

Lines changed: 226 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package handler
22

33
import (
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
2247
func 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
152184
func 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
196268
func 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/
255457
func 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

Comments
 (0)