feat: add community enhancements — password reset, audit logs, multi-source backup

Three community-requested features:

1. CLI password reset: `backupx reset-password --username admin --password xxx`
   Docker users can run via `docker exec`. No full app init needed.

2. Audit logging: async fire-and-forget audit trail for all key operations
   (login, CRUD on tasks/targets/records, settings changes).
   New UI page at /audit with category filter and pagination.

3. Multi-source path backup: file backup tasks now support multiple source
   directories packed into a single tar archive. Backward compatible with
   existing single sourcePath field.
This commit is contained in:
Awuqing
2026-03-30 23:04:37 +08:00
parent b01828e3b4
commit 09698cc767
47 changed files with 1902 additions and 263 deletions

View File

@@ -22,14 +22,23 @@ func (r *FileRunner) Type() string {
}
func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*RunResult, error) {
sourcePath := filepath.Clean(strings.TrimSpace(task.SourcePath))
if sourcePath == "" {
// 解析源路径列表:优先 SourcePaths回退 SourcePath
sourcePaths := task.SourcePaths
if len(sourcePaths) == 0 && strings.TrimSpace(task.SourcePath) != "" {
sourcePaths = []string{task.SourcePath}
}
if len(sourcePaths) == 0 {
return nil, fmt.Errorf("source path is required")
}
info, err := os.Stat(sourcePath)
if err != nil {
return nil, fmt.Errorf("stat source path: %w", err)
// 验证所有路径存在
for _, sp := range sourcePaths {
cleaned := filepath.Clean(strings.TrimSpace(sp))
if _, err := os.Stat(cleaned); err != nil {
return nil, fmt.Errorf("stat source path %s: %w", cleaned, err)
}
}
tempDir, artifactPath, err := createTempArtifact(task.TempDir, task.Name, "tar")
if err != nil {
return nil, err
@@ -41,69 +50,88 @@ func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*R
defer artifactFile.Close()
tw := tar.NewWriter(artifactFile)
defer tw.Close()
baseParent := filepath.Dir(sourcePath)
excludes := normalizeExcludePatterns(task.ExcludePatterns)
writer.WriteLine(fmt.Sprintf("开始打包文件备份:%s", sourcePath))
fileCount := 0
dirCount := 0
walkErr := filepath.Walk(sourcePath, func(currentPath string, currentInfo os.FileInfo, walkErr error) error {
if walkErr != nil {
writer.WriteLine(fmt.Sprintf("⚠ 无法访问 %s: %v", currentPath, walkErr))
return nil
}
relPath, err := filepath.Rel(baseParent, currentPath)
totalFileCount := 0
totalDirCount := 0
for i, sp := range sourcePaths {
sourcePath := filepath.Clean(strings.TrimSpace(sp))
info, err := os.Stat(sourcePath)
if err != nil {
return err
return nil, fmt.Errorf("stat source path: %w", err)
}
archiveName := filepath.ToSlash(relPath)
if shouldExcludeEntry(archiveName, currentInfo.IsDir(), excludes) {
if currentInfo.IsDir() {
writer.WriteLine(fmt.Sprintf("跳过排除目录 %s", archiveName))
return filepath.SkipDir
baseParent := filepath.Dir(sourcePath)
writer.WriteLine(fmt.Sprintf("开始打包源路径 [%d/%d]: %s", i+1, len(sourcePaths), sourcePath))
fileCount := 0
dirCount := 0
walkErr := filepath.Walk(sourcePath, func(currentPath string, currentInfo os.FileInfo, walkErr error) error {
if walkErr != nil {
writer.WriteLine(fmt.Sprintf("⚠ 无法访问 %s: %v", currentPath, walkErr))
return nil
}
return nil
}
if currentPath == sourcePath && currentInfo.IsDir() {
return nil
}
if currentInfo.IsDir() {
dirCount++
writer.WriteLine(fmt.Sprintf("📁 进入目录 %s", archiveName))
}
header, err := tar.FileInfoHeader(currentInfo, "")
if err != nil {
return err
}
header.Name = archiveName
if err := tw.WriteHeader(header); err != nil {
return err
}
if currentInfo.Mode().IsRegular() {
file, err := os.Open(currentPath)
relPath, err := filepath.Rel(baseParent, currentPath)
if err != nil {
return err
}
defer file.Close()
if _, err := io.CopyN(tw, file, currentInfo.Size()); err != nil && err != io.EOF {
archiveName := filepath.ToSlash(relPath)
if shouldExcludeEntry(archiveName, currentInfo.IsDir(), excludes) {
if currentInfo.IsDir() {
writer.WriteLine(fmt.Sprintf("跳过排除目录 %s", archiveName))
return filepath.SkipDir
}
return nil
}
if currentPath == sourcePath && currentInfo.IsDir() {
return nil
}
if currentInfo.IsDir() {
dirCount++
writer.WriteLine(fmt.Sprintf("📁 进入目录 %s", archiveName))
}
header, err := tar.FileInfoHeader(currentInfo, "")
if err != nil {
return err
}
fileCount++
if fileCount%100 == 0 {
writer.WriteLine(fmt.Sprintf("已打包 %d 个文件...", fileCount))
header.Name = archiveName
if err := tw.WriteHeader(header); err != nil {
return err
}
if currentInfo.Mode().IsRegular() {
file, err := os.Open(currentPath)
if err != nil {
return err
}
defer file.Close()
if _, err := io.CopyN(tw, file, currentInfo.Size()); err != nil && err != io.EOF {
return err
}
fileCount++
if fileCount%100 == 0 {
writer.WriteLine(fmt.Sprintf("已打包 %d 个文件...", fileCount))
}
}
return nil
})
if walkErr != nil {
return nil, fmt.Errorf("walk source path %s: %w", sourcePath, walkErr)
}
return nil
})
if walkErr != nil {
return nil, fmt.Errorf("walk source path: %w", walkErr)
if info.IsDir() {
writer.WriteLine(fmt.Sprintf("源路径 [%d/%d] 打包完成(%d 个目录,%d 个文件)", i+1, len(sourcePaths), dirCount, fileCount))
} else {
writer.WriteLine(fmt.Sprintf("源路径 [%d/%d] 文件打包完成", i+1, len(sourcePaths)))
}
totalFileCount += fileCount
totalDirCount += dirCount
}
if info.IsDir() {
writer.WriteLine(fmt.Sprintf("目录打包完成(%d 个目录,%d 个文件)", dirCount, fileCount))
} else {
writer.WriteLine("文件打包完成")
if len(sourcePaths) > 1 {
writer.WriteLine(fmt.Sprintf("全部源路径打包完成(共 %d 个目录,%d 个文件)", totalDirCount, totalFileCount))
}
return &RunResult{ArtifactPath: artifactPath, FileName: filepath.Base(artifactPath), TempDir: tempDir}, nil
}
@@ -114,7 +142,12 @@ func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath stri
return fmt.Errorf("open tar artifact: %w", err)
}
defer artifactFile.Close()
targetParent := filepath.Dir(filepath.Clean(strings.TrimSpace(task.SourcePath)))
// 恢复目标:优先取 SourcePaths 的第一个路径的父目录,回退 SourcePath
restoreSource := task.SourcePath
if len(task.SourcePaths) > 0 {
restoreSource = task.SourcePaths[0]
}
targetParent := filepath.Dir(filepath.Clean(strings.TrimSpace(restoreSource)))
if err := os.MkdirAll(targetParent, 0o755); err != nil {
return fmt.Errorf("create restore parent: %w", err)
}