add manual backup

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2025-09-18 14:05:23 +02:00
parent 1644389f7f
commit 4d8aac54a1
10 changed files with 893 additions and 5 deletions
+8
View File
@@ -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)
+6
View File
@@ -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,
}
}
+9
View File
@@ -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,
}
}
+74
View File
@@ -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)
}
+1
View File
@@ -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())
+582
View File
@@ -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
}
+37
View File
@@ -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();
}
};
+3 -1
View File
@@ -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 />
+169
View File
@@ -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>