mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-10 06:08:42 +02:00
@@ -36,6 +36,10 @@ const (
|
||||
ROUTE_V1_UPDATE_AVAILABLE = "/api/v1/update/available"
|
||||
ROUTE_V1_UPDATE_AVAILABLE_CACHED = "/api/v1/update/available/cached"
|
||||
ROUTE_V1_UPDATE = "/api/v1/update"
|
||||
// backup
|
||||
ROUTE_V1_BACKUP_CREATE = "/api/v1/backup/create"
|
||||
ROUTE_V1_BACKUP_LIST = "/api/v1/backup/list"
|
||||
ROUTE_V1_BACKUP_DOWNLOAD = "/api/v1/backup/download/:filename"
|
||||
// user
|
||||
ROUTE_V1_USER = "/api/v1/user"
|
||||
ROUTE_V1_USER_ID = "/api/v1/user/:id"
|
||||
@@ -419,6 +423,10 @@ func setupRoutes(
|
||||
// update
|
||||
GET(ROUTE_V1_UPDATE, middleware.SessionHandler, controllers.Update.GetUpdateDetails).
|
||||
POST(ROUTE_V1_UPDATE, middleware.SessionHandler, controllers.Update.RunUpdate).
|
||||
// backup
|
||||
POST(ROUTE_V1_BACKUP_CREATE, middleware.SessionHandler, controllers.Backup.CreateBackup).
|
||||
GET(ROUTE_V1_BACKUP_LIST, middleware.SessionHandler, controllers.Backup.ListBackups).
|
||||
GET(ROUTE_V1_BACKUP_DOWNLOAD, middleware.SessionHandler, controllers.Backup.DownloadBackup).
|
||||
// import
|
||||
POST(ROUTE_V1_IMPORT, middleware.SessionHandler, controllers.Import.Import)
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ type Controllers struct {
|
||||
SSO *controller.SSO
|
||||
Update *controller.Update
|
||||
Import *controller.Import
|
||||
Backup *controller.Backup
|
||||
}
|
||||
|
||||
// NewControllers creates a collection of controllers
|
||||
@@ -168,6 +169,10 @@ func NewControllers(
|
||||
Common: common,
|
||||
ImportService: services.Import,
|
||||
}
|
||||
backup := &controller.Backup{
|
||||
Common: common,
|
||||
BackupService: services.Backup,
|
||||
}
|
||||
|
||||
return &Controllers{
|
||||
Asset: asset,
|
||||
@@ -196,5 +201,6 @@ func NewControllers(
|
||||
SSO: sso,
|
||||
Update: update,
|
||||
Import: importController,
|
||||
Backup: backup,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ type Services struct {
|
||||
SSO *service.SSO
|
||||
Update *service.Update
|
||||
Import *service.Import
|
||||
Backup *service.Backup
|
||||
}
|
||||
|
||||
// NewServices creates a collection of services
|
||||
@@ -49,6 +50,7 @@ func NewServices(
|
||||
certMagicConfig *certmagic.Config,
|
||||
certMagicCache *certmagic.Cache,
|
||||
licenseServerURL string,
|
||||
filePath string,
|
||||
) *Services {
|
||||
common := service.Common{
|
||||
Logger: logger,
|
||||
@@ -212,6 +214,12 @@ func NewServices(
|
||||
SessionService: sessionService,
|
||||
// MSALClient: msalClient, this dependency is set AFTER this function
|
||||
}
|
||||
backupService := &service.Backup{
|
||||
Common: common,
|
||||
OptionService: optionService,
|
||||
DB: db,
|
||||
FilePath: filePath,
|
||||
}
|
||||
updateService := &service.Update{
|
||||
Common: common,
|
||||
OptionService: optionService,
|
||||
@@ -252,5 +260,6 @@ func NewServices(
|
||||
SSO: ssoService,
|
||||
Update: updateService,
|
||||
Import: importService,
|
||||
Backup: backupService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/phishingclub/phishingclub/service"
|
||||
)
|
||||
|
||||
type Backup struct {
|
||||
Common
|
||||
BackupService *service.Backup
|
||||
}
|
||||
|
||||
// CreateBackup starts a backup operation
|
||||
func (b *Backup) CreateBackup(g *gin.Context) {
|
||||
session, _, ok := b.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
err := b.BackupService.CreateBackup(g, session)
|
||||
if ok := b.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
b.Response.OK(g, gin.H{
|
||||
"message": "backup started",
|
||||
})
|
||||
}
|
||||
|
||||
// ListBackups returns a list of available backup files
|
||||
func (b *Backup) ListBackups(g *gin.Context) {
|
||||
session, _, ok := b.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
backups, err := b.BackupService.ListBackups(g, session)
|
||||
if ok := b.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
b.Response.OK(g, backups)
|
||||
}
|
||||
|
||||
// DownloadBackup serves a backup file for download
|
||||
func (b *Backup) DownloadBackup(g *gin.Context) {
|
||||
session, _, ok := b.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
filename := g.Param("filename")
|
||||
if filename == "" {
|
||||
g.JSON(http.StatusBadRequest, gin.H{"error": "filename is required"})
|
||||
return
|
||||
}
|
||||
|
||||
backupFile, err := b.BackupService.GetBackupFile(g, session, filename)
|
||||
if ok := b.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
defer backupFile.Close()
|
||||
|
||||
// set headers for file download
|
||||
g.Header("Content-Description", "File Transfer")
|
||||
g.Header("Content-Transfer-Encoding", "binary")
|
||||
g.Header("Content-Disposition", "attachment; filename="+filename)
|
||||
g.Header("Content-Type", "application/octet-stream")
|
||||
|
||||
// serve the file content directly from the secure file handle
|
||||
g.DataFromReader(http.StatusOK, -1, "application/octet-stream", backupFile, nil)
|
||||
}
|
||||
@@ -225,6 +225,7 @@ func main() {
|
||||
certMagicConfig,
|
||||
certMagicCache,
|
||||
licenseServer,
|
||||
*flagFilePath,
|
||||
)
|
||||
// get entra-id options and setup msal client
|
||||
ssoOpt, err := services.SSO.GetSSOOptionWithoutAuth(context.Background())
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/phishingclub/phishingclub/data"
|
||||
"github.com/phishingclub/phishingclub/errs"
|
||||
"github.com/phishingclub/phishingclub/model"
|
||||
"github.com/phishingclub/phishingclub/validate"
|
||||
)
|
||||
|
||||
// BackupFile represents a backup file available for download
|
||||
type BackupFile struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
RelativePath string `json:"relativePath"`
|
||||
}
|
||||
|
||||
type Backup struct {
|
||||
Common
|
||||
OptionService *Option
|
||||
DB *gorm.DB
|
||||
FilePath string // base file path for application data
|
||||
}
|
||||
|
||||
// BackupStatus represents the status of a backup operation
|
||||
type BackupStatus struct {
|
||||
IsRunning bool `json:"isRunning"`
|
||||
IsComplete bool `json:"isComplete"`
|
||||
HasError bool `json:"hasError"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
StartTime time.Time `json:"startTime"`
|
||||
EndTime time.Time `json:"endTime"`
|
||||
BackupPath string `json:"backupPath"`
|
||||
Progress string `json:"progress"`
|
||||
}
|
||||
|
||||
// BackupResult represents the result of a backup operation
|
||||
type BackupResult struct {
|
||||
BackupPath string `json:"backupPath"`
|
||||
DatabaseSize int64 `json:"databaseSize"`
|
||||
FilesSize int64 `json:"filesSize"`
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
}
|
||||
|
||||
var (
|
||||
currentBackupStatus *BackupStatus
|
||||
)
|
||||
|
||||
// CreateBackup creates a backup of the database and files
|
||||
func (b *Backup) CreateBackup(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
) error {
|
||||
ae := NewAuditEvent("Backup.CreateBackup", session)
|
||||
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil {
|
||||
b.LogAuthError(err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
b.AuditLogNotAuthorized(ae)
|
||||
return errors.New("unauthorized")
|
||||
}
|
||||
|
||||
// check if backup is already running
|
||||
if currentBackupStatus != nil && currentBackupStatus.IsRunning {
|
||||
return errors.New("backup already in progress")
|
||||
}
|
||||
|
||||
// initialize backup status
|
||||
currentBackupStatus = &BackupStatus{
|
||||
IsRunning: true,
|
||||
IsComplete: false,
|
||||
HasError: false,
|
||||
StartTime: time.Now(),
|
||||
Progress: "starting backup",
|
||||
}
|
||||
|
||||
// run backup synchronously to lock interface
|
||||
err = b.performBackup(ctx)
|
||||
currentBackupStatus.IsRunning = false
|
||||
currentBackupStatus.EndTime = time.Now()
|
||||
|
||||
if err != nil {
|
||||
currentBackupStatus.HasError = true
|
||||
currentBackupStatus.ErrorMessage = err.Error()
|
||||
b.Logger.Errorw("backup failed", "error", err)
|
||||
b.AuditLogAuthorized(ae)
|
||||
return errs.Wrap(err)
|
||||
} else {
|
||||
currentBackupStatus.IsComplete = true
|
||||
currentBackupStatus.Progress = "backup completed"
|
||||
b.Logger.Infow("backup completed successfully", "path", currentBackupStatus.BackupPath)
|
||||
|
||||
// automatically cleanup old backups to maintain maximum of 3
|
||||
currentBackupStatus.Progress = "cleaning up old backups"
|
||||
cleanupErr := b.CleanupOldBackups(ctx, session, 3)
|
||||
if cleanupErr != nil {
|
||||
b.Logger.Warnw("failed to cleanup old backups", "error", cleanupErr)
|
||||
// don't fail the backup operation if cleanup fails
|
||||
} else {
|
||||
b.Logger.Debugw("cleaned up old backups, keeping latest 3")
|
||||
}
|
||||
ae.Details["backupPath"] = currentBackupStatus.BackupPath
|
||||
}
|
||||
|
||||
if currentBackupStatus.HasError {
|
||||
ae.Details["error"] = currentBackupStatus.ErrorMessage
|
||||
b.AuditLogAuthorized(ae)
|
||||
return errs.Wrap(errors.New(currentBackupStatus.ErrorMessage))
|
||||
}
|
||||
|
||||
b.AuditLogAuthorized(ae)
|
||||
return nil
|
||||
}
|
||||
|
||||
// performBackup performs the actual backup operation
|
||||
func (b *Backup) performBackup(ctx context.Context) error {
|
||||
timestamp := time.Now().Format("20060102-150405")
|
||||
filesPath := strings.TrimSuffix(b.FilePath, "/")
|
||||
backupDir := filepath.Join(filesPath, "backups", fmt.Sprintf("backup-%s", timestamp))
|
||||
|
||||
// create backup directory
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
currentBackupStatus.Progress = "backing up database"
|
||||
b.Logger.Debugw("starting database backup")
|
||||
|
||||
// backup database directly to backup root
|
||||
if err := b.backupDatabase(ctx, backupDir); err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
currentBackupStatus.Progress = "backing up files"
|
||||
b.Logger.Debugw("starting files backup")
|
||||
|
||||
// backup files directly to backup root (preserving directory structure)
|
||||
if err := b.backupFiles(backupDir); err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
currentBackupStatus.Progress = "compressing backup"
|
||||
b.Logger.Debugw("compressing backup")
|
||||
|
||||
// compress backup
|
||||
backupArchive := backupDir + ".tar.gz"
|
||||
if err := b.compressBackup(backupDir, backupArchive); err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// remove uncompressed backup directory
|
||||
if err := os.RemoveAll(backupDir); err != nil {
|
||||
b.Logger.Warnw("failed to remove uncompressed backup directory", "error", err)
|
||||
}
|
||||
|
||||
currentBackupStatus.BackupPath = backupArchive
|
||||
return nil
|
||||
}
|
||||
|
||||
// backupDatabase creates a backup of the sqlite database
|
||||
func (b *Backup) backupDatabase(ctx context.Context, backupPath string) error {
|
||||
// get the underlying sql.DB
|
||||
sqlDB, err := b.DB.DB()
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// execute wal checkpoint to ensure all data is written to main db file
|
||||
_, err = sqlDB.ExecContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
if err != nil {
|
||||
b.Logger.Warnw("failed to checkpoint wal", "error", err)
|
||||
// continue anyway as this is not critical
|
||||
}
|
||||
|
||||
// extract database path from DSN
|
||||
dbPath := b.extractDatabasePath()
|
||||
|
||||
// copy main database file
|
||||
if err := b.copyFile(dbPath, filepath.Join(backupPath, "db.sqlite3")); err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// copy wal file if it exists
|
||||
walPath := dbPath + "-wal"
|
||||
if _, err := os.Stat(walPath); err == nil {
|
||||
if err := b.copyFile(walPath, filepath.Join(backupPath, "db.sqlite3-wal")); err != nil {
|
||||
b.Logger.Debugw("failed to copy wal file", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// copy shm file if it exists
|
||||
shmPath := dbPath + "-shm"
|
||||
if _, err := os.Stat(shmPath); err == nil {
|
||||
if err := b.copyFile(shmPath, filepath.Join(backupPath, "db.sqlite3-shm")); err != nil {
|
||||
b.Logger.Debugw("failed to copy shm file", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// backupFiles creates a backup of application files
|
||||
func (b *Backup) backupFiles(backupPath string) error {
|
||||
// files are stored in the path specified by --files flag
|
||||
// remove trailing slash if present for consistent path joining
|
||||
filesPath := strings.TrimSuffix(b.FilePath, "/")
|
||||
|
||||
filesToBackup := []string{"assets", "attachments", "certs"}
|
||||
|
||||
for _, dir := range filesToBackup {
|
||||
srcPath := filepath.Join(filesPath, dir)
|
||||
dstPath := filepath.Join(backupPath, dir)
|
||||
|
||||
// check if source directory exists
|
||||
if _, err := os.Stat(srcPath); os.IsNotExist(err) {
|
||||
b.Logger.Debugw("directory does not exist, skipping", "path", srcPath)
|
||||
continue
|
||||
}
|
||||
|
||||
// copy directory
|
||||
if err := b.copyDir(srcPath, dstPath); err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractDatabasePath extracts the database file path from the GORM DSN
|
||||
func (b *Backup) extractDatabasePath() string {
|
||||
// get the underlying sql.DB to access the data source name
|
||||
sqlDB, err := b.DB.DB()
|
||||
if err != nil {
|
||||
b.Logger.Debugw("failed to get sql.DB, using default path", "error", err)
|
||||
return "./db.sqlite3"
|
||||
}
|
||||
|
||||
// try to get database list to find the actual file path
|
||||
rows, err := sqlDB.Query("PRAGMA database_list")
|
||||
if err != nil {
|
||||
b.Logger.Debugw("failed to query database list, using default path", "error", err)
|
||||
return "./db.sqlite3"
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var seq int
|
||||
var name, file string
|
||||
err := rows.Scan(&seq, &name, &file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// main database has seq=0 and name="main"
|
||||
if seq == 0 && name == "main" && file != "" {
|
||||
b.Logger.Debugw("found database path from PRAGMA database_list", "path", file)
|
||||
return file
|
||||
}
|
||||
}
|
||||
|
||||
// fallback to default
|
||||
b.Logger.Debugw("could not determine database path from PRAGMA, using default")
|
||||
return "./db.sqlite3"
|
||||
}
|
||||
|
||||
// compressBackup compresses the backup directory into a tar.gz file
|
||||
func (b *Backup) compressBackup(srcDir, dstFile string) error {
|
||||
file, err := os.Create(dstFile)
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
gzipWriter := gzip.NewWriter(file)
|
||||
defer gzipWriter.Close()
|
||||
|
||||
tarWriter := tar.NewWriter(gzipWriter)
|
||||
defer tarWriter.Close()
|
||||
|
||||
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get relative path
|
||||
relPath, err := filepath.Rel(srcDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create tar header
|
||||
header, err := tar.FileInfoHeader(info, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = relPath
|
||||
|
||||
// write header
|
||||
if err := tarWriter.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// write file content if it's a regular file
|
||||
if info.Mode().IsRegular() {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(tarWriter, file)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// copyFile copies a file from src to dst
|
||||
func (b *Backup) copyFile(src, dst string) error {
|
||||
sourceFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
defer sourceFile.Close()
|
||||
|
||||
// create destination directory if it doesn't exist
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
destFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
_, err = io.Copy(destFile, sourceFile)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// copyDir recursively copies a directory from src to dst
|
||||
func (b *Backup) copyDir(src, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get relative path
|
||||
relPath, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstPath := filepath.Join(dst, relPath)
|
||||
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(dstPath, info.Mode())
|
||||
}
|
||||
|
||||
return b.copyFile(path, dstPath)
|
||||
})
|
||||
}
|
||||
|
||||
// CleanupOldBackups removes old backup files to save disk space
|
||||
func (b *Backup) CleanupOldBackups(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
keepCount int,
|
||||
) error {
|
||||
ae := NewAuditEvent("Backup.CleanupOldBackups", session)
|
||||
ae.Details["keepCount"] = keepCount
|
||||
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil {
|
||||
b.LogAuthError(err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
b.AuditLogNotAuthorized(ae)
|
||||
return errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
filesPath := strings.TrimSuffix(b.FilePath, "/")
|
||||
backupDir := filepath.Join(filesPath, "backups")
|
||||
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||
return nil // no backups directory
|
||||
}
|
||||
|
||||
// get all backup files
|
||||
files, err := os.ReadDir(backupDir)
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// filter backup files and sort by modification time
|
||||
var backupFiles []os.FileInfo
|
||||
for _, file := range files {
|
||||
if strings.HasPrefix(file.Name(), "backup-") && strings.HasSuffix(file.Name(), ".tar.gz") {
|
||||
info, err := file.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
backupFiles = append(backupFiles, info)
|
||||
}
|
||||
}
|
||||
|
||||
// if we have more backups than we want to keep, delete the oldest ones
|
||||
if len(backupFiles) > keepCount {
|
||||
// sort by modification time (oldest first)
|
||||
for i := 0; i < len(backupFiles)-1; i++ {
|
||||
for j := i + 1; j < len(backupFiles); j++ {
|
||||
if backupFiles[i].ModTime().After(backupFiles[j].ModTime()) {
|
||||
backupFiles[i], backupFiles[j] = backupFiles[j], backupFiles[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// delete oldest files
|
||||
filesToDelete := len(backupFiles) - keepCount
|
||||
deletedFiles := []string{}
|
||||
for i := 0; i < filesToDelete; i++ {
|
||||
filePath := filepath.Join(backupDir, backupFiles[i].Name())
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
b.Logger.Warnw("failed to delete old backup", "file", filePath, "error", err)
|
||||
} else {
|
||||
b.Logger.Debugw("deleted old backup", "file", filePath)
|
||||
deletedFiles = append(deletedFiles, backupFiles[i].Name())
|
||||
}
|
||||
}
|
||||
ae.Details["deletedFiles"] = deletedFiles
|
||||
ae.Details["deletedCount"] = len(deletedFiles)
|
||||
}
|
||||
|
||||
ae.Details["totalBackups"] = len(backupFiles)
|
||||
b.AuditLogAuthorized(ae)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListBackups returns a list of available backup files
|
||||
func (b *Backup) ListBackups(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
) ([]BackupFile, error) {
|
||||
ae := NewAuditEvent("Backup.ListBackups", session)
|
||||
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil {
|
||||
b.LogAuthError(err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
b.AuditLogNotAuthorized(ae)
|
||||
return nil, errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
filesPath := strings.TrimSuffix(b.FilePath, "/")
|
||||
backupDir := filepath.Join(filesPath, "backups")
|
||||
|
||||
// check if backup directory exists
|
||||
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||
return []BackupFile{}, nil // return empty list if no backups directory
|
||||
}
|
||||
|
||||
// read backup directory
|
||||
files, err := os.ReadDir(backupDir)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
|
||||
var backupFiles []BackupFile
|
||||
for _, file := range files {
|
||||
if strings.HasPrefix(file.Name(), "backup-") && strings.HasSuffix(file.Name(), ".tar.gz") {
|
||||
info, err := file.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
backupFiles = append(backupFiles, BackupFile{
|
||||
Name: file.Name(),
|
||||
Size: info.Size(),
|
||||
CreatedAt: info.ModTime(),
|
||||
RelativePath: filepath.Join("backups", file.Name()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// sort by creation time (newest first)
|
||||
for i := 0; i < len(backupFiles)-1; i++ {
|
||||
for j := i + 1; j < len(backupFiles); j++ {
|
||||
if backupFiles[i].CreatedAt.Before(backupFiles[j].CreatedAt) {
|
||||
backupFiles[i], backupFiles[j] = backupFiles[j], backupFiles[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ae.Details["backupCount"] = len(backupFiles)
|
||||
b.AuditLogAuthorized(ae)
|
||||
return backupFiles, nil
|
||||
}
|
||||
|
||||
// GetBackupFile returns a file handle to a backup file if it exists and is valid
|
||||
func (b *Backup) GetBackupFile(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
filename string,
|
||||
) (*os.File, error) {
|
||||
ae := NewAuditEvent("Backup.DownloadBackup", session)
|
||||
ae.Details["filename"] = filename
|
||||
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil {
|
||||
b.LogAuthError(err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
b.AuditLogNotAuthorized(ae)
|
||||
return nil, errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
// validate filename - must be a backup file
|
||||
if !strings.HasPrefix(filename, "backup-") || !strings.HasSuffix(filename, ".tar.gz") {
|
||||
b.Logger.Debugw("invalid backup filename format", "filename", filename)
|
||||
return nil, validate.WrapErrorWithField(errors.New("invalid backup filename"), "filename")
|
||||
}
|
||||
|
||||
// get backup directory path
|
||||
filesPath := strings.TrimSuffix(b.FilePath, "/")
|
||||
backupDir := filepath.Join(filesPath, "backups")
|
||||
|
||||
// use os.OpenRoot for secure file access within backup directory
|
||||
root, err := os.OpenRoot(backupDir)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
defer root.Close()
|
||||
|
||||
// try to stat the file using the secure root - this prevents directory traversal
|
||||
info, err := root.Stat(filename)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
b.Logger.Debugw("backup file not found", "filename", filename)
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
|
||||
if !info.Mode().IsRegular() {
|
||||
b.Logger.Debugw("requested file is not a regular file", "filename", filename)
|
||||
return nil, validate.WrapErrorWithField(errors.New("not a regular file"), "filename")
|
||||
}
|
||||
|
||||
// open the file using the secure root - this maintains security throughout
|
||||
file, err := root.Open(filename)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
|
||||
ae.Details["backupSize"] = info.Size()
|
||||
b.AuditLogAuthorized(ae)
|
||||
return file, nil
|
||||
}
|
||||
@@ -234,6 +234,43 @@ export class API {
|
||||
*/
|
||||
runUpdate: async () => {
|
||||
return await postJSON(this.getPath(`/update`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a backup
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
createBackup: async () => {
|
||||
return await postJSON(this.getPath(`/backup/create`));
|
||||
},
|
||||
|
||||
/**
|
||||
* List available backups
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
listBackups: async () => {
|
||||
return await getJSON(this.getPath(`/backup/list`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Download a backup file
|
||||
* @param {string} filename - name of the backup file
|
||||
* @returns {Promise<Blob>}
|
||||
*/
|
||||
downloadBackup: async (filename) => {
|
||||
const response = await fetch(
|
||||
this.getPath(`/backup/download/${encodeURIComponent(filename)}`),
|
||||
{
|
||||
method: 'GET',
|
||||
credentials: 'same-origin'
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download backup: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.blob();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script>
|
||||
export let backgroundColor = 'bg-cta-blue';
|
||||
/*
|
||||
/*
|
||||
linear-gradient(#64a6e6, #9198e5);
|
||||
*/
|
||||
export let css = '';
|
||||
export let size = 'medium';
|
||||
export let disabled = false;
|
||||
</script>
|
||||
|
||||
<button
|
||||
@@ -12,6 +13,7 @@
|
||||
class:w-32={size === 'small'}
|
||||
class:w-36={size === 'medium'}
|
||||
class:w-60={size === 'large'}
|
||||
{disabled}
|
||||
on:click
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -45,6 +45,12 @@
|
||||
let updateAvailable = false;
|
||||
let isCheckingUpdate = false;
|
||||
|
||||
// Backup functionality
|
||||
let isBackupModalVisible = false;
|
||||
let isCreatingBackup = false;
|
||||
let availableBackups = [];
|
||||
let isLoadingBackups = false;
|
||||
|
||||
let ssoForm = null;
|
||||
let isSSOModalVisible = false;
|
||||
let updateSSOError = '';
|
||||
@@ -91,6 +97,7 @@
|
||||
await refreshSSO();
|
||||
await refreshVersion();
|
||||
await refreshUpdateCached();
|
||||
await refreshBackupList();
|
||||
if (!ssoSettingsFormValues.redirectURL) {
|
||||
ssoSettingsFormValues.redirectURL = `${location.origin}/api/v1/sso/entra-id/auth`;
|
||||
}
|
||||
@@ -206,6 +213,69 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshBackupList() {
|
||||
isLoadingBackups = true;
|
||||
try {
|
||||
const res = await api.application.listBackups();
|
||||
if (res.success) {
|
||||
availableBackups = res.data || [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('failed to refresh backup list', e);
|
||||
availableBackups = [];
|
||||
} finally {
|
||||
isLoadingBackups = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadBackup(filename) {
|
||||
try {
|
||||
const blob = await api.application.downloadBackup(filename);
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
addToast('Backup downloaded successfully', 'Success');
|
||||
} catch (e) {
|
||||
console.error('failed to download backup', e);
|
||||
addToast('Failed to download backup', 'Error');
|
||||
}
|
||||
}
|
||||
|
||||
function openBackupModal() {
|
||||
isBackupModalVisible = true;
|
||||
}
|
||||
|
||||
function closeBackupModal() {
|
||||
isBackupModalVisible = false;
|
||||
}
|
||||
|
||||
async function createBackup() {
|
||||
isCreatingBackup = true;
|
||||
try {
|
||||
const res = await api.application.createBackup();
|
||||
if (res.success) {
|
||||
addToast('Backup created successfully', 'Success');
|
||||
closeBackupModal();
|
||||
await refreshBackupList(); // Refresh backup list
|
||||
} else {
|
||||
addToast('Failed to create backup', 'Error');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('failed to create backup', e);
|
||||
addToast('Failed to create backup', 'Error');
|
||||
} finally {
|
||||
isCreatingBackup = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testLogLevel() {
|
||||
try {
|
||||
const res = await api.log.testLevels();
|
||||
@@ -506,6 +576,55 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup Section -->
|
||||
<div
|
||||
class="bg-white p-6 rounded-lg shadow-sm border border-gray-100 min-h-[300px] flex flex-col"
|
||||
>
|
||||
<h2 class="text-xl font-semibold text-gray-700 mb-6">Backup</h2>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="space-y-4">
|
||||
<p class="text-gray-600 text-sm">
|
||||
Create a backup of database, assets, attachments and certificates.
|
||||
</p>
|
||||
|
||||
{#if availableBackups.length > 0}
|
||||
<div class="bg-gray-50 p-3 rounded-md">
|
||||
<h4 class="font-medium text-gray-900 mb-2">Available:</h4>
|
||||
<div class="space-y-3">
|
||||
{#each availableBackups as backup}
|
||||
<div class="flex items-start justify-between gap-4 text-sm">
|
||||
<div class="flex flex-col min-w-0 flex-1">
|
||||
<span class="text-gray-700 text-xs font-medium">
|
||||
{new Date(backup.createdAt).toLocaleString()}
|
||||
</span>
|
||||
<span class="text-gray-400 text-xs">
|
||||
{(backup.size / 1024 / 1024).toFixed(1)} MB
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded transition-colors flex-shrink-0"
|
||||
on:click={() => downloadBackup(backup.name)}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if !isLoadingBackups}
|
||||
<div class="bg-gray-50 p-3 rounded-md text-sm text-gray-600">
|
||||
No backups available yet.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-auto pt-4">
|
||||
<Button size={'large'} on:click={openBackupModal} disabled={isCreatingBackup}>
|
||||
Create Backup
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -830,4 +949,54 @@
|
||||
bind:isVisible={isSSODeleteAlertVisible}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if isBackupModalVisible}
|
||||
<Modal
|
||||
headerText="Create Backup"
|
||||
bind:visible={isBackupModalVisible}
|
||||
onClose={closeBackupModal}
|
||||
isSubmitting={isCreatingBackup}
|
||||
>
|
||||
<FormGrid on:submit={createBackup} isSubmitting={isCreatingBackup}>
|
||||
<FormColumns>
|
||||
<FormColumn>
|
||||
<div class="space-y-4">
|
||||
<p>This will create a backup file that can be downloaded from the settings page.</p>
|
||||
<p>
|
||||
<strong>Note:</strong> This is not a substitute for having proper automated and tested
|
||||
backup and recovery plans at the operating system level.
|
||||
</p>
|
||||
<div class="bg-blue-50 p-4 rounded-md">
|
||||
<h3 class="font-semibold text-blue-800 mb-2">What will be backed up:</h3>
|
||||
<ul class="text-sm text-blue-700 space-y-1">
|
||||
<li>• SQLite database (including WAL files)</li>
|
||||
<li>• Asset files</li>
|
||||
<li>• Attachment files</li>
|
||||
<li>• Certificate files</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="bg-yellow-50 p-4 rounded-md">
|
||||
<h3 class="font-semibold text-yellow-800 mb-2">Important:</h3>
|
||||
<ul class="text-sm text-yellow-700 space-y-1">
|
||||
<li>• Large databases may take significant time to backup</li>
|
||||
<li>• Operations may be affected during the backup process</li>
|
||||
<li>• Ensure you have sufficient disk space</li>
|
||||
<li>
|
||||
• Only the 3 most recent backups are kept (older ones are automatically deleted)
|
||||
</li>
|
||||
<li>• The backup does not include config.json or the application binary</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</FormColumn>
|
||||
</FormColumns>
|
||||
<FormFooter
|
||||
closeModal={closeBackupModal}
|
||||
isSubmitting={isCreatingBackup}
|
||||
okText={isCreatingBackup ? 'Creating Backup...' : 'Create Backup'}
|
||||
/>
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -78,13 +78,10 @@
|
||||
<div class="bg-white dark:bg-half-devil-gray p-6 rounded-lg shadow-sm">
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<div class="border-t pt-6 border-grayblue-light dark:border-devil-gray">
|
||||
{#if isUpdateAvailable}
|
||||
{#if isUpdateAvailable || true}
|
||||
<div class="bg-pleasant-gray dark:bg-devil-gray p-4 rounded-md mb-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-active-blue dark:text-cta-light-blue">
|
||||
Update Available!
|
||||
</h3>
|
||||
<p class="text-grayblue-dark">Version {newVersion} is now available</p>
|
||||
<p class="mt-2">
|
||||
<a
|
||||
@@ -101,6 +98,9 @@
|
||||
updated manually.
|
||||
</p>
|
||||
{/if}
|
||||
<p class="mt-4 text-sm text-gray-600">
|
||||
Consider creating a backup before updating to ensure you can restore if needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user