mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-08-17 16:07:18 +02:00
import example templates on install
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -89,6 +89,7 @@ func NewControllers(
|
||||
OptionRepository: repositories.Option,
|
||||
PasswordHasher: *utillities.PasswordHasher,
|
||||
DB: db,
|
||||
ImportService: services.Import,
|
||||
}
|
||||
health := &controller.Health{}
|
||||
log := &controller.Log{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+59
-39
@@ -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
|
||||
|
||||
@@ -271,6 +271,14 @@ export class API {
|
||||
}
|
||||
|
||||
return await response.blob();
|
||||
},
|
||||
|
||||
/**
|
||||
* Install example templates from GitHub during setup
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
installTemplates: async () => {
|
||||
return await postJSON(this.getPath(`/install/templates`));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
<span
|
||||
class={`
|
||||
mt-2 text-sm font-medium text-center
|
||||
${currentStep > 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 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div class="bg-white px-4 shadow sm:rounded-lg sm:px-10">
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 px-4 shadow sm:rounded-lg sm:px-10 transition-colors duration-200"
|
||||
>
|
||||
<FormGrid bind:bindTo={form}>
|
||||
<FormColumns>
|
||||
<FormColumn>
|
||||
@@ -239,17 +260,64 @@
|
||||
Confirm Password
|
||||
</PasswordField>
|
||||
</div>
|
||||
{:else if currentStep === 2}
|
||||
<div class="text-center py-8" id="step-2">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
Example Templates
|
||||
</h3>
|
||||
<div class="space-y-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
<p>Install example templates?</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Includes phishing pages and emails from
|
||||
<a
|
||||
href="https://github.com/phishingclub/templates"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 font-medium"
|
||||
>
|
||||
template builder
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<label class="flex items-center justify-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={installTemplates}
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Yes, install example templates
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{#if templatesError}
|
||||
<div
|
||||
class="mt-4 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md"
|
||||
>
|
||||
<p class="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<strong>Note:</strong>
|
||||
{templatesError}
|
||||
</p>
|
||||
<p class="text-xs text-yellow-700 dark:text-yellow-300 mt-1">
|
||||
You can manually import templates later from Settings.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center py-8" id="step-{currentStep}">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Welcome to Phishing Club</h3>
|
||||
<div class="space-y-4 text-sm text-gray-600">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
Welcome to Phishing Club
|
||||
</h3>
|
||||
<div class="space-y-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
<p>
|
||||
Get started by reading our
|
||||
<a
|
||||
href="https://phishing.club/guide/introduction/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-600 hover:text-blue-800 font-medium"
|
||||
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 font-medium"
|
||||
>
|
||||
user guide
|
||||
</a>
|
||||
@@ -258,7 +326,7 @@
|
||||
Have questions, bugs or suggestions? <br /> Contact us at
|
||||
<a
|
||||
href="mailto:support@phishing.club"
|
||||
class="text-blue-600 hover:text-blue-800 font-medium"
|
||||
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 font-medium"
|
||||
>
|
||||
support@phishing.club
|
||||
</a>
|
||||
|
||||
Reference in New Issue
Block a user