import example templates on install

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2025-10-11 10:15:33 +02:00
parent 595bb880cd
commit 61b1019ba3
7 changed files with 312 additions and 49 deletions
+3 -1
View File
@@ -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).
+1
View File
@@ -89,6 +89,7 @@ func NewControllers(
OptionRepository: repositories.Option,
PasswordHasher: *utillities.PasswordHasher,
DB: db,
ImportService: services.Import,
}
health := &controller.Health{}
log := &controller.Log{
+157
View File
@@ -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)
}
+7
View File
@@ -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
View File
@@ -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
+8
View File
@@ -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`));
}
};
+77 -9
View File
@@ -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>