From 4d8aac54a11932cc8494282a1263462f2c2ced1b Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Thu, 18 Sep 2025 14:05:23 +0200 Subject: [PATCH] add manual backup Signed-off-by: Ronni Skansing --- backend/app/administration.go | 8 + backend/app/controllers.go | 6 + backend/app/services.go | 9 + backend/controller/backup.go | 74 +++ backend/main.go | 1 + backend/service/backup.go | 582 ++++++++++++++++++ frontend/src/lib/api/api.js | 37 ++ frontend/src/lib/components/Button.svelte | 4 +- frontend/src/routes/settings/+page.svelte | 169 +++++ .../src/routes/settings/update/+page.svelte | 8 +- 10 files changed, 893 insertions(+), 5 deletions(-) create mode 100644 backend/controller/backup.go create mode 100644 backend/service/backup.go diff --git a/backend/app/administration.go b/backend/app/administration.go index 87b8444..d82f556 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -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) diff --git a/backend/app/controllers.go b/backend/app/controllers.go index 33d59b3..943206e 100644 --- a/backend/app/controllers.go +++ b/backend/app/controllers.go @@ -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, } } diff --git a/backend/app/services.go b/backend/app/services.go index 761aab8..daab92b 100644 --- a/backend/app/services.go +++ b/backend/app/services.go @@ -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, } } diff --git a/backend/controller/backup.go b/backend/controller/backup.go new file mode 100644 index 0000000..2516d2b --- /dev/null +++ b/backend/controller/backup.go @@ -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) +} diff --git a/backend/main.go b/backend/main.go index 3d4cae7..ef69f2a 100644 --- a/backend/main.go +++ b/backend/main.go @@ -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()) diff --git a/backend/service/backup.go b/backend/service/backup.go new file mode 100644 index 0000000..fbbddd7 --- /dev/null +++ b/backend/service/backup.go @@ -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 +} diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index a196582..8f232cc 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -234,6 +234,43 @@ export class API { */ runUpdate: async () => { return await postJSON(this.getPath(`/update`)); + }, + + /** + * Create a backup + * @returns {Promise} + */ + createBackup: async () => { + return await postJSON(this.getPath(`/backup/create`)); + }, + + /** + * List available backups + * @returns {Promise} + */ + listBackups: async () => { + return await getJSON(this.getPath(`/backup/list`)); + }, + + /** + * Download a backup file + * @param {string} filename - name of the backup file + * @returns {Promise} + */ + 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(); } }; diff --git a/frontend/src/lib/components/Button.svelte b/frontend/src/lib/components/Button.svelte index 086cab8..bc73341 100644 --- a/frontend/src/lib/components/Button.svelte +++ b/frontend/src/lib/components/Button.svelte @@ -1,10 +1,11 @@ + + {/each} + + + {:else if !isLoadingBackups} +
+ No backups available yet. +
+ {/if} + +
+ +
+ + @@ -830,4 +949,54 @@ bind:isVisible={isSSODeleteAlertVisible} /> {/if} + + {#if isBackupModalVisible} + + + + +
+

This will create a backup file that can be downloaded from the settings page.

+

+ Note: This is not a substitute for having proper automated and tested + backup and recovery plans at the operating system level. +

+
+

What will be backed up:

+
    +
  • • SQLite database (including WAL files)
  • +
  • • Asset files
  • +
  • • Attachment files
  • +
  • • Certificate files
  • +
+
+ +
+

Important:

+
    +
  • • Large databases may take significant time to backup
  • +
  • • Operations may be affected during the backup process
  • +
  • • Ensure you have sufficient disk space
  • +
  • + • Only the 3 most recent backups are kept (older ones are automatically deleted) +
  • +
  • • The backup does not include config.json or the application binary
  • +
+
+
+
+
+ +
+
+ {/if} diff --git a/frontend/src/routes/settings/update/+page.svelte b/frontend/src/routes/settings/update/+page.svelte index 2333be1..ecee318 100644 --- a/frontend/src/routes/settings/update/+page.svelte +++ b/frontend/src/routes/settings/update/+page.svelte @@ -78,13 +78,10 @@
- {#if isUpdateAvailable} + {#if isUpdateAvailable || true}