2026-03-13 10:38:19 +08:00
|
|
|
|
package admin
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-04-25 09:24:17 +08:00
|
|
|
|
"sync"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
2026-03-13 10:38:19 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
2026-03-14 17:48:21 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
2026-03-13 10:38:19 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-25 09:24:17 +08:00
|
|
|
|
const (
|
|
|
|
|
|
restorePasswordMaxFailures = 5
|
|
|
|
|
|
restorePasswordBlockWindow = 15 * time.Minute
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
var backupRestoreAttemptLimiter = newRestoreAttemptLimiter(restorePasswordMaxFailures, restorePasswordBlockWindow)
|
|
|
|
|
|
|
2026-03-13 10:38:19 +08:00
|
|
|
|
type BackupHandler struct {
|
|
|
|
|
|
backupService *service.BackupService
|
2026-03-14 17:48:21 +08:00
|
|
|
|
userService *service.UserService
|
2026-03-13 10:38:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 17:48:21 +08:00
|
|
|
|
func NewBackupHandler(backupService *service.BackupService, userService *service.UserService) *BackupHandler {
|
|
|
|
|
|
return &BackupHandler{
|
|
|
|
|
|
backupService: backupService,
|
|
|
|
|
|
userService: userService,
|
|
|
|
|
|
}
|
2026-03-13 10:38:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── S3 配置 ───
|
|
|
|
|
|
|
|
|
|
|
|
func (h *BackupHandler) GetS3Config(c *gin.Context) {
|
|
|
|
|
|
cfg, err := h.backupService.GetS3Config(c.Request.Context())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
response.Success(c, cfg)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (h *BackupHandler) UpdateS3Config(c *gin.Context) {
|
|
|
|
|
|
var req service.BackupS3Config
|
|
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
cfg, err := h.backupService.UpdateS3Config(c.Request.Context(), req)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
response.Success(c, cfg)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (h *BackupHandler) TestS3Connection(c *gin.Context) {
|
|
|
|
|
|
var req service.BackupS3Config
|
|
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
err := h.backupService.TestS3Connection(c.Request.Context(), req)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.Success(c, gin.H{"ok": false, "message": err.Error()})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
response.Success(c, gin.H{"ok": true, "message": "connection successful"})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 定时备份 ───
|
|
|
|
|
|
|
|
|
|
|
|
func (h *BackupHandler) GetSchedule(c *gin.Context) {
|
|
|
|
|
|
cfg, err := h.backupService.GetSchedule(c.Request.Context())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
response.Success(c, cfg)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (h *BackupHandler) UpdateSchedule(c *gin.Context) {
|
|
|
|
|
|
var req service.BackupScheduleConfig
|
|
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
cfg, err := h.backupService.UpdateSchedule(c.Request.Context(), req)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
response.Success(c, cfg)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 备份操作 ───
|
|
|
|
|
|
|
|
|
|
|
|
type CreateBackupRequest struct {
|
|
|
|
|
|
ExpireDays *int `json:"expire_days"` // nil=使用默认值14,0=永不过期
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (h *BackupHandler) CreateBackup(c *gin.Context) {
|
|
|
|
|
|
var req CreateBackupRequest
|
|
|
|
|
|
_ = c.ShouldBindJSON(&req) // 允许空 body
|
|
|
|
|
|
|
|
|
|
|
|
expireDays := 14 // 默认14天过期
|
|
|
|
|
|
if req.ExpireDays != nil {
|
|
|
|
|
|
expireDays = *req.ExpireDays
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 20:03:08 +08:00
|
|
|
|
record, err := h.backupService.StartBackup(c.Request.Context(), "manual", expireDays)
|
2026-03-13 10:38:19 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-16 20:03:08 +08:00
|
|
|
|
response.Accepted(c, record)
|
2026-03-13 10:38:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (h *BackupHandler) ListBackups(c *gin.Context) {
|
|
|
|
|
|
records, err := h.backupService.ListBackups(c.Request.Context())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if records == nil {
|
|
|
|
|
|
records = []service.BackupRecord{}
|
|
|
|
|
|
}
|
|
|
|
|
|
response.Success(c, gin.H{"items": records})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (h *BackupHandler) GetBackup(c *gin.Context) {
|
|
|
|
|
|
backupID := c.Param("id")
|
|
|
|
|
|
if backupID == "" {
|
|
|
|
|
|
response.BadRequest(c, "backup ID is required")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
record, err := h.backupService.GetBackupRecord(c.Request.Context(), backupID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
response.Success(c, record)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (h *BackupHandler) DeleteBackup(c *gin.Context) {
|
|
|
|
|
|
backupID := c.Param("id")
|
|
|
|
|
|
if backupID == "" {
|
|
|
|
|
|
response.BadRequest(c, "backup ID is required")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := h.backupService.DeleteBackup(c.Request.Context(), backupID); err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
response.Success(c, gin.H{"deleted": true})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (h *BackupHandler) GetDownloadURL(c *gin.Context) {
|
|
|
|
|
|
backupID := c.Param("id")
|
|
|
|
|
|
if backupID == "" {
|
|
|
|
|
|
response.BadRequest(c, "backup ID is required")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
url, err := h.backupService.GetBackupDownloadURL(c.Request.Context(), backupID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
response.Success(c, gin.H{"url": url})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 17:48:21 +08:00
|
|
|
|
// ─── 恢复操作(需要重新输入管理员密码) ───
|
|
|
|
|
|
|
|
|
|
|
|
type RestoreBackupRequest struct {
|
|
|
|
|
|
Password string `json:"password" binding:"required"`
|
|
|
|
|
|
}
|
2026-03-13 10:38:19 +08:00
|
|
|
|
|
2026-04-25 09:24:17 +08:00
|
|
|
|
type restoreAttemptState struct {
|
|
|
|
|
|
failuresUntil time.Time
|
|
|
|
|
|
failureCount int
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type restoreAttemptLimiter struct {
|
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
|
maxFailures int
|
|
|
|
|
|
window time.Duration
|
|
|
|
|
|
states map[int64]restoreAttemptState
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func newRestoreAttemptLimiter(maxFailures int, window time.Duration) *restoreAttemptLimiter {
|
|
|
|
|
|
return &restoreAttemptLimiter{
|
|
|
|
|
|
maxFailures: maxFailures,
|
|
|
|
|
|
window: window,
|
|
|
|
|
|
states: make(map[int64]restoreAttemptState),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (l *restoreAttemptLimiter) allow(userID int64, now time.Time) (bool, time.Duration) {
|
|
|
|
|
|
l.mu.Lock()
|
|
|
|
|
|
defer l.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
|
|
state, ok := l.states[userID]
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
return true, 0
|
|
|
|
|
|
}
|
|
|
|
|
|
if now.After(state.failuresUntil) {
|
|
|
|
|
|
delete(l.states, userID)
|
|
|
|
|
|
return true, 0
|
|
|
|
|
|
}
|
|
|
|
|
|
if state.failureCount < l.maxFailures {
|
|
|
|
|
|
return true, 0
|
|
|
|
|
|
}
|
|
|
|
|
|
return false, state.failuresUntil.Sub(now)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (l *restoreAttemptLimiter) recordFailure(userID int64, now time.Time) {
|
|
|
|
|
|
l.mu.Lock()
|
|
|
|
|
|
defer l.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
|
|
state := l.states[userID]
|
|
|
|
|
|
if now.After(state.failuresUntil) {
|
|
|
|
|
|
state = restoreAttemptState{}
|
|
|
|
|
|
}
|
|
|
|
|
|
state.failureCount++
|
|
|
|
|
|
state.failuresUntil = now.Add(l.window)
|
|
|
|
|
|
l.states[userID] = state
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (l *restoreAttemptLimiter) recordSuccess(userID int64) {
|
|
|
|
|
|
l.mu.Lock()
|
|
|
|
|
|
defer l.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
|
|
delete(l.states, userID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 10:38:19 +08:00
|
|
|
|
func (h *BackupHandler) RestoreBackup(c *gin.Context) {
|
|
|
|
|
|
backupID := c.Param("id")
|
|
|
|
|
|
if backupID == "" {
|
|
|
|
|
|
response.BadRequest(c, "backup ID is required")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-14 17:48:21 +08:00
|
|
|
|
|
|
|
|
|
|
var req RestoreBackupRequest
|
|
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
|
response.BadRequest(c, "password is required for restore operation")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 从上下文获取当前管理员用户 ID
|
|
|
|
|
|
sub, ok := middleware.GetAuthSubjectFromContext(c)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
response.Unauthorized(c, "unauthorized")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 09:24:17 +08:00
|
|
|
|
if allowed, _ := backupRestoreAttemptLimiter.allow(sub.UserID, time.Now()); !allowed {
|
|
|
|
|
|
response.ErrorFrom(c, infraerrors.TooManyRequests("RESTORE_PASSWORD_RATE_LIMITED", "too many failed password attempts, please try again later"))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 17:48:21 +08:00
|
|
|
|
// 获取管理员用户并验证密码
|
|
|
|
|
|
user, err := h.userService.GetByID(c.Request.Context(), sub.UserID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if !user.CheckPassword(req.Password) {
|
2026-04-25 09:24:17 +08:00
|
|
|
|
backupRestoreAttemptLimiter.recordFailure(sub.UserID, time.Now())
|
2026-03-14 17:48:21 +08:00
|
|
|
|
response.BadRequest(c, "incorrect admin password")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-04-25 09:24:17 +08:00
|
|
|
|
backupRestoreAttemptLimiter.recordSuccess(sub.UserID)
|
2026-03-14 17:48:21 +08:00
|
|
|
|
|
2026-03-16 20:03:08 +08:00
|
|
|
|
record, err := h.backupService.StartRestore(c.Request.Context(), backupID)
|
|
|
|
|
|
if err != nil {
|
2026-03-13 10:38:19 +08:00
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-16 20:03:08 +08:00
|
|
|
|
response.Accepted(c, record)
|
2026-03-13 10:38:19 +08:00
|
|
|
|
}
|