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

@@ -0,0 +1,40 @@
package http
import (
"strconv"
"strings"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type AuditHandler struct {
auditService *service.AuditService
}
func NewAuditHandler(auditService *service.AuditService) *AuditHandler {
return &AuditHandler{auditService: auditService}
}
func (h *AuditHandler) List(c *gin.Context) {
category := strings.TrimSpace(c.Query("category"))
limit := 50
offset := 0
if v := strings.TrimSpace(c.Query("limit")); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
limit = parsed
}
}
if v := strings.TrimSpace(c.Query("offset")); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed >= 0 {
offset = parsed
}
}
result, err := h.auditService.List(c.Request.Context(), category, limit, offset)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}

View File

@@ -0,0 +1,29 @@
package http
import (
"fmt"
"backupx/server/internal/service"
"github.com/gin-gonic/gin"
)
// recordAudit 从 gin context 中提取用户信息并记录审计日志nil 安全)
func recordAudit(c *gin.Context, auditService *service.AuditService, category, action, targetType, targetID, targetName, detail string) {
if auditService == nil {
return
}
username := ""
if subject, exists := c.Get(contextUserSubjectKey); exists {
username = fmt.Sprintf("%v", subject)
}
auditService.Record(service.AuditEntry{
Username: username,
Category: category,
Action: action,
TargetType: targetType,
TargetID: targetID,
TargetName: targetName,
Detail: detail,
ClientIP: c.ClientIP(),
})
}

View File

@@ -16,11 +16,12 @@ import (
)
type BackupRecordHandler struct {
service *service.BackupRecordService
service *service.BackupRecordService
auditService *service.AuditService
}
func NewBackupRecordHandler(recordService *service.BackupRecordService) *BackupRecordHandler {
return &BackupRecordHandler{service: recordService}
func NewBackupRecordHandler(recordService *service.BackupRecordService, auditService *service.AuditService) *BackupRecordHandler {
return &BackupRecordHandler{service: recordService, auditService: auditService}
}
func (h *BackupRecordHandler) List(c *gin.Context) {
@@ -129,6 +130,7 @@ func (h *BackupRecordHandler) Restore(c *gin.Context) {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "backup_record", "restore", "backup_record", fmt.Sprintf("%d", id), "", "")
response.Success(c, gin.H{"restored": true})
}
@@ -141,6 +143,7 @@ func (h *BackupRecordHandler) Delete(c *gin.Context) {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "backup_record", "delete", "backup_record", fmt.Sprintf("%d", id), "", "")
response.Success(c, gin.H{"deleted": true})
}

View File

@@ -1,17 +1,20 @@
package http
import (
"fmt"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type BackupRunHandler struct {
service *service.BackupExecutionService
service *service.BackupExecutionService
auditService *service.AuditService
}
func NewBackupRunHandler(executionService *service.BackupExecutionService) *BackupRunHandler {
return &BackupRunHandler{service: executionService}
func NewBackupRunHandler(executionService *service.BackupExecutionService, auditService *service.AuditService) *BackupRunHandler {
return &BackupRunHandler{service: executionService, auditService: auditService}
}
func (h *BackupRunHandler) Run(c *gin.Context) {
@@ -24,5 +27,6 @@ func (h *BackupRunHandler) Run(c *gin.Context) {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "backup_task", "run", "backup_task", fmt.Sprintf("%d", id), "", "手动触发备份")
response.Success(c, record)
}

View File

@@ -1,6 +1,8 @@
package http
import (
"fmt"
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
@@ -8,11 +10,12 @@ import (
)
type BackupTaskHandler struct {
service *service.BackupTaskService
service *service.BackupTaskService
auditService *service.AuditService
}
func NewBackupTaskHandler(taskService *service.BackupTaskService) *BackupTaskHandler {
return &BackupTaskHandler{service: taskService}
func NewBackupTaskHandler(taskService *service.BackupTaskService, auditService *service.AuditService) *BackupTaskHandler {
return &BackupTaskHandler{service: taskService, auditService: auditService}
}
func (h *BackupTaskHandler) List(c *gin.Context) {
@@ -48,6 +51,7 @@ func (h *BackupTaskHandler) Create(c *gin.Context) {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "backup_task", "create", "backup_task", fmt.Sprintf("%d", item.ID), item.Name, "")
response.Success(c, item)
}
@@ -66,6 +70,7 @@ func (h *BackupTaskHandler) Update(c *gin.Context) {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "backup_task", "update", "backup_task", fmt.Sprintf("%d", item.ID), item.Name, "")
response.Success(c, item)
}
@@ -78,6 +83,7 @@ func (h *BackupTaskHandler) Delete(c *gin.Context) {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "backup_task", "delete", "backup_task", fmt.Sprintf("%d", id), "", "")
response.Success(c, gin.H{"deleted": true})
}
@@ -105,5 +111,10 @@ func (h *BackupTaskHandler) Toggle(c *gin.Context) {
response.Error(c, err)
return
}
action := "enable"
if !enabled {
action = "disable"
}
recordAudit(c, h.auditService, "backup_task", action, "backup_task", fmt.Sprintf("%d", id), item.Name, "")
response.Success(c, item)
}

View File

@@ -0,0 +1,30 @@
package http
import (
"backupx/server/internal/apperror"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type DatabaseHandler struct {
service *service.DatabaseDiscoveryService
}
func NewDatabaseHandler(service *service.DatabaseDiscoveryService) *DatabaseHandler {
return &DatabaseHandler{service: service}
}
func (h *DatabaseHandler) Discover(c *gin.Context) {
var input service.DatabaseDiscoverInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("DATABASE_DISCOVER_INVALID", "数据库发现参数不合法", err))
return
}
result, err := h.service.Discover(c.Request.Context(), input)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, result)
}

View File

@@ -15,22 +15,24 @@ import (
)
type RouterDependencies struct {
Config config.Config
Version string
Logger *zap.Logger
AuthService *service.AuthService
SystemService *service.SystemService
StorageTargetService *service.StorageTargetService
BackupTaskService *service.BackupTaskService
BackupExecutionService *service.BackupExecutionService
BackupRecordService *service.BackupRecordService
NotificationService *service.NotificationService
DashboardService *service.DashboardService
SettingsService *service.SettingsService
NodeService *service.NodeService
JWTManager *security.JWTManager
UserRepository repository.UserRepository
SystemConfigRepo repository.SystemConfigRepository
Config config.Config
Version string
Logger *zap.Logger
AuthService *service.AuthService
SystemService *service.SystemService
StorageTargetService *service.StorageTargetService
BackupTaskService *service.BackupTaskService
BackupExecutionService *service.BackupExecutionService
BackupRecordService *service.BackupRecordService
NotificationService *service.NotificationService
DashboardService *service.DashboardService
SettingsService *service.SettingsService
NodeService *service.NodeService
DatabaseDiscoveryService *service.DatabaseDiscoveryService
AuditService *service.AuditService
JWTManager *security.JWTManager
UserRepository repository.UserRepository
SystemConfigRepo repository.SystemConfigRepository
}
func NewRouter(deps RouterDependencies) *gin.Engine {
@@ -42,13 +44,14 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
authHandler := NewAuthHandler(deps.AuthService)
systemHandler := NewSystemHandler(deps.SystemService)
storageTargetHandler := NewStorageTargetHandler(deps.StorageTargetService)
backupTaskHandler := NewBackupTaskHandler(deps.BackupTaskService)
backupRunHandler := NewBackupRunHandler(deps.BackupExecutionService)
backupRecordHandler := NewBackupRecordHandler(deps.BackupRecordService)
storageTargetHandler := NewStorageTargetHandler(deps.StorageTargetService, deps.AuditService)
backupTaskHandler := NewBackupTaskHandler(deps.BackupTaskService, deps.AuditService)
backupRunHandler := NewBackupRunHandler(deps.BackupExecutionService, deps.AuditService)
backupRecordHandler := NewBackupRecordHandler(deps.BackupRecordService, deps.AuditService)
notificationHandler := NewNotificationHandler(deps.NotificationService)
dashboardHandler := NewDashboardHandler(deps.DashboardService)
settingsHandler := NewSettingsHandler(deps.SettingsService)
settingsHandler := NewSettingsHandler(deps.SettingsService, deps.AuditService)
auditHandler := NewAuditHandler(deps.AuditService)
api := engine.Group("/api")
{
@@ -73,6 +76,7 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
storageTargets.POST("", storageTargetHandler.Create)
storageTargets.PUT("/:id", storageTargetHandler.Update)
storageTargets.DELETE("/:id", storageTargetHandler.Delete)
storageTargets.PUT("/:id/star", storageTargetHandler.ToggleStar)
storageTargets.POST("/test", storageTargetHandler.TestConnection)
storageTargets.POST("/:id/test", storageTargetHandler.TestSavedConnection)
storageTargets.GET("/:id/usage", storageTargetHandler.GetUsage)
@@ -119,6 +123,17 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
settings.GET("", settingsHandler.Get)
settings.PUT("", settingsHandler.Update)
auditLogs := api.Group("/audit-logs")
auditLogs.Use(AuthMiddleware(deps.JWTManager))
auditLogs.GET("", auditHandler.List)
if deps.DatabaseDiscoveryService != nil {
databaseHandler := NewDatabaseHandler(deps.DatabaseDiscoveryService)
database := api.Group("/database")
database.Use(AuthMiddleware(deps.JWTManager))
database.POST("/discover", databaseHandler.Discover)
}
nodeHandler := NewNodeHandler(deps.NodeService)
nodes := api.Group("/nodes")
nodes.Use(AuthMiddleware(deps.JWTManager))

View File

@@ -9,10 +9,11 @@ import (
type SettingsHandler struct {
settingsService *service.SettingsService
auditService *service.AuditService
}
func NewSettingsHandler(settingsService *service.SettingsService) *SettingsHandler {
return &SettingsHandler{settingsService: settingsService}
func NewSettingsHandler(settingsService *service.SettingsService, auditService *service.AuditService) *SettingsHandler {
return &SettingsHandler{settingsService: settingsService, auditService: auditService}
}
func (h *SettingsHandler) Get(c *gin.Context) {
@@ -35,5 +36,6 @@ func (h *SettingsHandler) Update(c *gin.Context) {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "settings", "update", "settings", "", "", "")
response.Success(c, settings)
}

View File

@@ -12,7 +12,8 @@ import (
)
type StorageTargetHandler struct {
service *service.StorageTargetService
service *service.StorageTargetService
auditService *service.AuditService
}
type storageTargetGoogleDriveAuthRequest struct {
@@ -27,8 +28,8 @@ type storageTargetGoogleDriveAuthRequest struct {
FolderID string `json:"folderId"`
}
func NewStorageTargetHandler(service *service.StorageTargetService) *StorageTargetHandler {
return &StorageTargetHandler{service: service}
func NewStorageTargetHandler(service *service.StorageTargetService, auditService *service.AuditService) *StorageTargetHandler {
return &StorageTargetHandler{service: service, auditService: auditService}
}
func (h *StorageTargetHandler) List(c *gin.Context) {
@@ -64,6 +65,7 @@ func (h *StorageTargetHandler) Create(c *gin.Context) {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "storage_target", "create", "storage_target", fmt.Sprintf("%d", item.ID), item.Name, "")
response.Success(c, item)
}
@@ -82,6 +84,7 @@ func (h *StorageTargetHandler) Update(c *gin.Context) {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "storage_target", "update", "storage_target", fmt.Sprintf("%d", item.ID), item.Name, "")
response.Success(c, item)
}
@@ -94,6 +97,7 @@ func (h *StorageTargetHandler) Delete(c *gin.Context) {
response.Error(c, err)
return
}
recordAudit(c, h.auditService, "storage_target", "delete", "storage_target", fmt.Sprintf("%d", id), "", "")
response.Success(c, gin.H{"deleted": true})
}
@@ -230,6 +234,19 @@ func firstNonEmpty(values ...string) string {
return ""
}
func (h *StorageTargetHandler) ToggleStar(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
item, err := h.service.ToggleStar(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, item)
}
func (h *StorageTargetHandler) GetUsage(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {