diff --git a/backend/app/administration.go b/backend/app/administration.go index 51fc974..f3ff38f 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -77,7 +77,8 @@ const ( ROUTE_V1_OPTION = "/api/v1/option" ROUTE_V1_OPTION_GET = "/api/v1/option/:key" // installation - ROUTE_V1_INSTALL = "/api/v1/install" + ROUTE_V1_INSTALL = "/api/v1/install" + ROUTE_V1_INSTALL_TEMPLATES = "/api/v1/install/templates" // domain ROUTE_V1_DOMAIN = "/api/v1/domain" ROUTE_V1_DOMAIN_SUBSET = "/api/v1/domain/subset" @@ -246,6 +247,7 @@ func setupRoutes( POST(ROUTE_V1_USER_LOGOUT, controllers.User.Logout). // install POST(ROUTE_V1_INSTALL, middleware.SessionHandler, controllers.Installer.Install). + POST(ROUTE_V1_INSTALL_TEMPLATES, middleware.SessionHandler, controllers.Installer.InstallTemplates). // user GET(ROUTE_V1_USER, middleware.SessionHandler, controllers.User.GetAll). GET(ROUTE_V1_USER_ID, middleware.SessionHandler, controllers.User.GetByID). diff --git a/backend/app/controllers.go b/backend/app/controllers.go index fe2ad32..3f73c39 100644 --- a/backend/app/controllers.go +++ b/backend/app/controllers.go @@ -89,6 +89,7 @@ func NewControllers( OptionRepository: repositories.Option, PasswordHasher: *utillities.PasswordHasher, DB: db, + ImportService: services.Import, } health := &controller.Health{} log := &controller.Log{ diff --git a/backend/controller/install.go b/backend/controller/install.go index 862d074..520d5c8 100644 --- a/backend/controller/install.go +++ b/backend/controller/install.go @@ -1,7 +1,11 @@ package controller import ( + "encoding/json" "fmt" + "io" + "net/http" + "time" "github.com/go-errors/errors" "github.com/google/uuid" @@ -138,6 +142,7 @@ type Install struct { OptionRepository *repository.Option DB *gorm.DB PasswordHasher password.Argon2Hasher + ImportService *service.Import } // Install completes the installation by setting the initial administrators and options @@ -331,3 +336,155 @@ func (in *Install) install(g *gin.Context, tx *gorm.DB) bool { } return true } + +// InstallTemplates downloads and imports example templates from GitHub +func (in *Install) InstallTemplates(g *gin.Context) { + // handle session + session, user, ok := in.handleSession(g) + if !ok { + return + } + role := user.Role + if role == nil { + in.Logger.Error("failed to install templates - session contain no role") + in.Response.ServerError(g) + return + } + if !role.IsSuperAdministrator() { + in.Logger.Info("failed to install templates - not super admin") + in.Response.Forbidden(g) + return + } + + ctx := g.Request.Context() + + // check if already installed + isInstalled, err := in.OptionRepository.GetByKey(ctx, data.OptionKeyIsInstalled) + if err != nil { + in.Logger.Errorw("failed to install templates - could not get option", + "optionKey", data.OptionKeyIsInstalled, + "error", err, + ) + in.Response.ServerError(g) + return + } + if isInstalled.Value.String() != data.OptionValueIsInstalled { + in.Logger.Info("failed to install templates - installation not complete") + in.Response.BadRequestMessage(g, "Installation must be completed first") + return + } + + // create http client with timeout + client := &http.Client{ + Timeout: 30 * time.Second, + } + + // get latest release info from GitHub API + releaseURL := "https://api.github.com/repos/phishingclub/templates/releases/latest" + resp, err := client.Get(releaseURL) + if err != nil { + in.Logger.Errorw("failed to get latest release info", + "url", releaseURL, + "error", err, + ) + in.Response.ServerErrorMessage(g, "Failed to get latest templates release info") + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + in.Logger.Errorw("failed to get release info - bad status", + "url", releaseURL, + "status", resp.StatusCode, + ) + in.Response.ServerErrorMessage(g, fmt.Sprintf("Failed to get release info: HTTP %d", resp.StatusCode)) + return + } + + // parse release response + var release struct { + Assets []struct { + BrowserDownloadURL string `json:"browser_download_url"` + Name string `json:"name"` + } `json:"assets"` + } + + releaseBody, err := io.ReadAll(resp.Body) + if err != nil { + in.Logger.Errorw("failed to read release response", + "error", err, + ) + in.Response.ServerErrorMessage(g, "Failed to read release info") + return + } + + if err := json.Unmarshal(releaseBody, &release); err != nil { + in.Logger.Errorw("failed to parse release response", + "error", err, + ) + in.Response.ServerErrorMessage(g, "Failed to parse release info") + return + } + + if len(release.Assets) == 0 { + in.Logger.Error("no assets found in latest release") + in.Response.ServerErrorMessage(g, "No template assets found in latest release") + return + } + + // use the first asset (should be the templates zip) + templatesURL := release.Assets[0].BrowserDownloadURL + in.Logger.Infow("downloading templates from latest release", + "url", templatesURL, + "asset", release.Assets[0].Name, + ) + + // download the templates + resp2, err := client.Get(templatesURL) + if err != nil { + in.Logger.Errorw("failed to download templates", + "url", templatesURL, + "error", err, + ) + in.Response.ServerErrorMessage(g, "Failed to download templates from GitHub") + return + } + defer resp2.Body.Close() + + if resp2.StatusCode != http.StatusOK { + in.Logger.Errorw("failed to download templates - bad status", + "url", templatesURL, + "status", resp2.StatusCode, + ) + in.Response.ServerErrorMessage(g, fmt.Sprintf("Failed to download templates: HTTP %d", resp2.StatusCode)) + return + } + + // read the response body + body, err := io.ReadAll(resp2.Body) + if err != nil { + in.Logger.Errorw("failed to read templates response", + "error", err, + ) + in.Response.ServerErrorMessage(g, "Failed to read templates download") + return + } + + // import the templates from raw bytes (for global use, not company-specific) + summary, err := in.ImportService.ImportFromBytes(g, session, body, false, nil) + if err != nil { + in.Logger.Errorw("failed to import templates", + "error", err, + ) + in.Response.ServerErrorMessage(g, "Failed to import templates") + return + } + + in.Logger.Infow("successfully installed templates", + "assetsCreated", summary.AssetsCreated, + "pagesCreated", summary.PagesCreated, + "emailsCreated", summary.EmailsCreated, + ) + + in.Response.OK(g, summary) +} diff --git a/backend/install/installer.go b/backend/install/installer.go index 54d320f..783a67d 100644 --- a/backend/install/installer.go +++ b/backend/install/installer.go @@ -72,6 +72,7 @@ func InstallNonInteractive() error { fmt.Println("'journalctl -u phishingclub.service -f' to see logs") fmt.Println("'systemctl status phishingclub' to check status of the service") fmt.Println("") + fmt.Println() return nil @@ -387,6 +388,12 @@ func createDirectories() error { dirs := []string{ installDir, filepath.Join(installDir, dataDir), + filepath.Join(installDir, dataDir, "assets"), + filepath.Join(installDir, dataDir, "assets", "shared"), + filepath.Join(installDir, dataDir, "attachments"), + filepath.Join(installDir, dataDir, "attachments", "shared"), + filepath.Join(installDir, dataDir, "certs"), + filepath.Join(installDir, dataDir, "certs", "own-managed"), } for _, dir := range dirs { diff --git a/backend/service/import.go b/backend/service/import.go index af82232..e9a30ff 100644 --- a/backend/service/import.go +++ b/backend/service/import.go @@ -24,8 +24,8 @@ import ( ) const ( - MaxIndividualFileSize = 10 * 1024 * 1024 // 10MB per file - MaxTotalExtractedSize = 200 * 1024 * 1024 // 200MB total extracted + MaxIndividualFileSize = 100 * 1024 * 1024 // 100MB per file + MaxTotalExtractedSize = 500 * 1024 * 1024 // 500MB total extracted MaxFileCount = 20000 // Maximum files in zip MaxCompressionRatio = 100 // Maximum compression ratio (100:1) ) @@ -98,7 +98,6 @@ type ImportSummary struct { EmailsErrors int `json:"emails_errors"` EmailsErrorsList []ImportError `json:"emails_errors_list"` - // unspecificed errors Errors []ImportError `json:"errors"` } @@ -146,6 +145,12 @@ func (im *Import) Import( im.AuditLogNotAuthorized(ae) return nil, errs.ErrAuthorizationFailed } + + // check size limits + if fileHeader.Size > MaxIndividualFileSize { + return nil, fmt.Errorf("file too large: %d bytes (max: %d)", fileHeader.Size, MaxIndividualFileSize) + } + // handle file zipFile, err := fileHeader.Open() if err != nil { @@ -158,13 +163,30 @@ func (im *Import) Import( if err != nil { return nil, err } + + return im.ImportFromBytes(g, session, zipBytes, forCompany, companyID) +} + +// ImportFromBytes imports templates from raw zip bytes +func (im *Import) ImportFromBytes( + g *gin.Context, + session *model.Session, + zipBytes []byte, + forCompany bool, + companyID *uuid.UUID, +) (*ImportSummary, error) { + // check size limits + if int64(len(zipBytes)) > MaxIndividualFileSize { + return nil, fmt.Errorf("file too large: %d bytes (max: %d)", len(zipBytes), MaxIndividualFileSize) + } + readerAt := bytes.NewReader(zipBytes) r, err := zip.NewReader(readerAt, int64(len(zipBytes))) if err != nil { return nil, errs.Wrap(err) } - // Validate zip file structure and prevent zip bombs + // validate zip file structure and prevent zip bombs var totalUncompressedSize int64 fileCount := 0 for _, f := range r.File { @@ -200,9 +222,9 @@ func (im *Import) Import( } } for _, assetFile := range assetFiles { - // Compute relative path inside assets/ + // relative path inside assets/ relPath := strings.TrimPrefix(assetFile.Name, "assets/") - // Check DB for asset existence + // check if exists createdNew, err := im.createAssetFromZipFile(g, session, assetFile, relPath) if err != nil { summary.AssetsErrors++ @@ -221,7 +243,7 @@ func (im *Import) Import( } // 2. Find all folders containing a data.yaml and process them as template folders - // Map: folder path -> *zip.File for data.yaml + // map: folder path -> *zip.File for data.yaml templateFolders := make(map[string]*zip.File) for _, f := range r.File { if !f.FileInfo().IsDir() && strings.HasSuffix(f.Name, "data.yaml") { @@ -230,18 +252,17 @@ func (im *Import) Import( im.Logger.Debugw("Found template folder", "folder", dir, "dataYamlPath", f.Name) } } - im.Logger.Infow("Template folder discovery complete", "templateFolderCount", len(templateFolders)) // 3. Find all standalone template files without data.yaml standaloneTemplates := make(map[string][]*zip.File) // folder -> list of HTML files processedFolders := make(map[string]bool) - // Mark folders with data.yaml as processed + // mark folders with data.yaml as processed for folder := range templateFolders { processedFolders[folder] = true } - // Find standalone HTML files in folders without data.yaml + // find standalone HTML files in folders without data.yaml for _, f := range r.File { if f.FileInfo().IsDir() { continue @@ -250,12 +271,12 @@ func (im *Import) Import( fileName := filepath.Base(f.Name) folder := filepath.Dir(f.Name) - // Skip if this folder already has data.yaml or is assets folder + // skip if this folder already has data.yaml or is assets folder if processedFolders[folder] || strings.HasPrefix(f.Name, "assets/") { continue } - // Look for common template file patterns + // look for common template file patterns if strings.HasSuffix(fileName, ".html") && (fileName == "landing.html" || fileName == "index.html" || fileName == "email.html" || fileName == "landingpage.html") { @@ -264,10 +285,9 @@ func (im *Import) Import( } } - im.Logger.Infow("Template discovery complete", "foldersWithDataYaml", len(templateFolders), "standaloneTemplateFolders", len(standaloneTemplates)) + im.Logger.Debugw("Template discovery complete", "foldersWithDataYaml", len(templateFolders), "standaloneTemplateFolders", len(standaloneTemplates)) // 4. For each template folder, parse data.yaml and process pages/emails - // Helper for robust zip relative path calculation zipRelPath := func(folder, name string) string { cleanName := filepath.Clean(filepath.ToSlash(name)) cleanFolder := filepath.Clean(filepath.ToSlash(folder)) @@ -280,7 +300,7 @@ func (im *Import) Import( return strings.TrimPrefix(cleanName, cleanFolder+"/") } - im.Logger.Infow("Starting template folder processing", "totalTemplateFolders", len(templateFolders), "totalZipFiles", len(r.File)) + im.Logger.Debugw("Starting template folder processing", "totalTemplateFolders", len(templateFolders), "totalZipFiles", len(r.File)) // Pre-build file indices for efficient lookup buildFileIndex := func(folder string) map[string]*zip.File { @@ -310,9 +330,9 @@ func (im *Import) Import( } for folder, dataYamlFile := range templateFolders { - im.Logger.Infow("Processing template folder", "folder", folder, "dataYamlFile", dataYamlFile.Name) + im.Logger.Debugw("Processing template folder", "folder", folder, "dataYamlFile", dataYamlFile.Name) - // Read data.yaml content + // read data.yaml rc, err := dataYamlFile.Open() if err != nil { summary.Errors = append(summary.Errors, ImportError{ @@ -342,13 +362,13 @@ func (im *Import) Import( continue } - im.Logger.Infow("Parsed data.yaml", "folder", folder, "name", dataYaml.Name, "pageCount", len(dataYaml.Pages), "emailCount", len(dataYaml.Emails)) + im.Logger.Debugw("Parsed data.yaml", "folder", folder, "name", dataYaml.Name, "pageCount", len(dataYaml.Pages), "emailCount", len(dataYaml.Emails)) - // Build file index for this template folder + // build file index for this template folder fileIndex := buildFileIndex(folder) im.Logger.Debugw("Built file index for folder", "folder", folder, "fileCount", len(fileIndex)) - // Build sets of page and email file relative paths (relative to the template folder) + // build sets of page and email file relative paths (relative to the template folder) pageFiles := make(map[string]struct{}) for _, page := range dataYaml.Pages { cleanPageFile := filepath.Clean(filepath.ToSlash(page.File)) @@ -362,7 +382,7 @@ func (im *Import) Import( emailFiles[cleanEmailFile] = struct{}{} } - // For each file in the zip, check if it's under this template folder (including subfolders) + // for each file in the zip, check if it's under this template folder (including subfolders) for _, f := range r.File { if f.FileInfo().IsDir() { continue @@ -374,7 +394,7 @@ func (im *Import) Import( } relPath := zipRelPath(folder, f.Name) im.Logger.Debugw("Found file in template folder", "name", f.Name, "relPath", relPath) - // Skip data.yaml, page files, and email files by relative path + // skip data.yaml, page files, and email files by relative path if relPath == "data.yaml" { im.Logger.Debugw("Skipping data.yaml") continue @@ -388,7 +408,7 @@ func (im *Import) Import( continue } im.Logger.Debugw("Processing as asset", "relPath", relPath) - // Upload as asset, using relPath as the asset path + // upload as asset, using relPath as the asset path created, err := im.createAssetFromZipFile(g, session, f, relPath) if err != nil { summary.AssetsErrors++ @@ -406,12 +426,12 @@ func (im *Import) Import( } } - // PAGE IMPORT --- + // PAGE IMPORT for _, page := range dataYaml.Pages { cleanPageFile := filepath.Clean(filepath.ToSlash(page.File)) im.Logger.Debugw("Looking for page file", "pageName", page.Name, "pageFile", page.File, "cleanedFile", cleanPageFile, "folder", folder) - // Use file index for efficient lookup + // use file index for efficient lookup pageFile, pageFileFound := fileIndex[cleanPageFile] if !pageFileFound { pageFile, pageFileFound = fileIndex[strings.ToLower(cleanPageFile)] @@ -431,7 +451,7 @@ func (im *Import) Import( continue } - // Read HTML content + // read HTML content rc, err := pageFile.Open() if err != nil { summary.PagesErrors++ @@ -454,7 +474,7 @@ func (im *Import) Import( continue } - // Create new page + // create new page newPage := &model.Page{} name, err := vo.NewString64(page.Name) if err != nil { @@ -694,60 +714,60 @@ func (im *Import) createAssetFromZipFile( f *zip.File, relativePath string, ) (bool, error) { - // Check if asset already exists by path + // check if asset already exists by path existing, err := im.Asset.GetByPath(g.Request.Context(), session, relativePath) if err == nil && existing != nil { - // Asset already exists - check if it has a company ID (if so, skip it) + // asset already exists - check if it has a company ID (if so, skip it) if existing.CompanyID.IsSpecified() && !existing.CompanyID.IsNull() { - // Asset belongs to a company - skip it to maintain global-only policy + // asset belongs to a company - skip it to maintain global-only policy return false, nil } - // Asset already exists and is global + // asset already exists and is global return false, nil } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return false, err } - // Open the file from zip + // open the file from zip rc, err := f.Open() if err != nil { return false, err } defer rc.Close() - // Read file content + // read file content content, err := io.ReadAll(rc) if err != nil { return false, err } - // Use the provided relativePath for the asset path + // use the provided relativePath for the asset path fullRelativePath := relativePath - // Create asset model + // create asset model asset := &model.Asset{} - // Set the name from filename + // set the name from filename filename := filepath.Base(f.Name) if name, err := vo.NewOptionalString127(filename); err == nil { asset.Name = nullable.NewNullableWithValue(*name) } - // Set the relative path + // set the relative path if path, err := vo.NewRelativeFilePath(fullRelativePath); err == nil { asset.Path = nullable.NewNullableWithValue(*path) } // Assets are always global/shared - never set company ID - // Save asset to database first + // save asset to database first id, err := im.Asset.AssetRepository.Insert(g, asset) if err != nil { return false, err } - // Build the file system path - assets are always stored in shared folder + // build the file system path - assets are always stored in shared folder contextFolder := "shared" // ensure base asset directory exists diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index d139a7f..a631797 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -271,6 +271,14 @@ export class API { } return await response.blob(); + }, + + /** + * Install example templates from GitHub during setup + * @returns {Promise} + */ + installTemplates: async () => { + return await postJSON(this.getPath(`/install/templates`)); } }; diff --git a/frontend/src/routes/install/+page.svelte b/frontend/src/routes/install/+page.svelte index bcb926c..6a6d3d5 100644 --- a/frontend/src/routes/install/+page.svelte +++ b/frontend/src/routes/install/+page.svelte @@ -19,7 +19,7 @@ const appStateService = AppStateService.instance; // installation steps - will be updated based on edition - let steps = [{ name: 'Profile' }, { name: 'Complete' }]; + let steps = [{ name: 'Profile' }, { name: 'Templates' }, { name: 'Complete' }]; let currentStep = 1; let formError = ''; @@ -34,6 +34,10 @@ repeatPassword: '' }; + // templates step + let installTemplates = false; + let templatesError = ''; + // Removed edition detection - single unified installation // initialize theme system @@ -86,7 +90,10 @@ case 1: return validateProfile(); case 2: - // Step 2 is always Complete now - no validation needed + // Step 2 is templates - no validation needed + return true; + case 3: + // Step 3 is always Complete now - no validation needed return true; default: return true; @@ -132,6 +139,18 @@ return; } + // Install templates if requested + if (installTemplates) { + const templatesRes = await api.application.installTemplates(); + if (!templatesRes.success) { + templatesError = templatesRes.error || 'Failed to install templates'; + console.warn('failed to install templates', templatesRes.error); + // Continue with installation even if templates fail + } else { + console.info('templates installed successfully'); + } + } + appStateService.setIsInstalled(); // License configuration available in settings after installation console.info('install: setup completed - refreshing'); @@ -173,7 +192,7 @@ ? 'bg-blue-300 text-white' : currentStep === index + 1 ? 'bg-blue-600 text-white' - : 'bg-white text-gray-500 border-2 border-gray-300' + : 'bg-white dark:bg-gray-700 text-gray-500 dark:text-gray-300 border-2 border-gray-300 dark:border-gray-600' } `} > @@ -197,7 +216,7 @@ index + 1 || currentStep === index + 1 ? 'text-blue-600' : 'text-gray-500'} + ${currentStep > index + 1 || currentStep === index + 1 ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400'} `} > {step.name} @@ -208,7 +227,9 @@
-
+
@@ -239,17 +260,64 @@ Confirm Password
+ {:else if currentStep === 2} +
+

+ Example Templates +

+
+

Install example templates?

+

+ Includes phishing pages and emails from + + template builder + +

+
+
+ +
+ {#if templatesError} +
+

+ Note: + {templatesError} +

+

+ You can manually import templates later from Settings. +

+
+ {/if} +
{:else}
-

Welcome to Phishing Club

-
+

+ Welcome to Phishing Club +

+

Get started by reading our user guide @@ -258,7 +326,7 @@ Have questions, bugs or suggestions?
Contact us at support@phishing.club