Yimaru-BackEnd/internal/web_server/handlers/maintenance_handler.go

194 lines
5.7 KiB
Go

package handlers
import (
"Yimaru-Backend/internal/domain"
"crypto/subtle"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/gofiber/fiber/v2"
)
type resetAndReseedReq struct {
Confirm string `json:"confirm"`
}
func extractInsertStatement(sqlContent string, tableName string) (string, bool) {
pattern := fmt.Sprintf(`(?is)INSERT\s+INTO\s+%s\b.*?;`, regexp.QuoteMeta(tableName))
re := regexp.MustCompile(pattern)
statement := strings.TrimSpace(re.FindString(sqlContent))
if statement == "" {
return "", false
}
return statement, true
}
func resolveSeedDir(seedDir string) (string, error) {
cleanSeedDir := strings.TrimSpace(seedDir)
if cleanSeedDir == "" {
cleanSeedDir = "db/data"
}
// If absolute, use directly.
if filepath.IsAbs(cleanSeedDir) {
info, err := os.Stat(cleanSeedDir)
if err != nil {
return "", err
}
if !info.IsDir() {
return "", fmt.Errorf("seed dir is not a directory: %s", cleanSeedDir)
}
return cleanSeedDir, nil
}
candidates := make([]string, 0, 5)
// 1) Relative to current working directory.
candidates = append(candidates, filepath.Clean(cleanSeedDir))
// 2) Relative to executable directory (and parents).
if exePath, err := os.Executable(); err == nil {
exeDir := filepath.Dir(exePath)
candidates = append(candidates,
filepath.Join(exeDir, cleanSeedDir),
filepath.Join(exeDir, "..", cleanSeedDir),
filepath.Join(exeDir, "..", "..", cleanSeedDir),
)
}
for _, candidate := range candidates {
info, err := os.Stat(candidate)
if err != nil {
continue
}
if info.IsDir() {
return candidate, nil
}
}
return "", fmt.Errorf("seed directory not found (tried: %s)", strings.Join(candidates, ", "))
}
// ResetAndReseedDatabase godoc
// @Summary Reset and reseed database
// @Description Dangerous operation: clears and reseeds only course_categories, courses, and sub_courses from seed SQL files.
// @Tags internal
// @Accept json
// @Produce json
// @Param X-Seed-Reset-Token header string true "Reset token configured in DB_RESET_RESEED_TOKEN"
// @Param body body resetAndReseedReq true "Confirmation payload"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 403 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/internal/db/reset-reseed [post]
func (h *Handler) ResetAndReseedDatabase(c *fiber.Ctx) error {
if h.Cfg == nil || !h.Cfg.DBResetReseedEnabled {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Operation is disabled",
Error: "DB_RESET_RESEED_ENABLED must be set to true",
})
}
var req resetAndReseedReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if strings.TrimSpace(req.Confirm) != "RESET_AND_RESEED" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Confirmation required",
Error: `set confirm to "RESET_AND_RESEED"`,
})
}
expectedToken := strings.TrimSpace(h.Cfg.DBResetReseedToken)
if expectedToken != "" {
providedToken := strings.TrimSpace(c.Get("X-Seed-Reset-Token"))
if subtle.ConstantTimeCompare([]byte(providedToken), []byte(expectedToken)) != 1 {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Invalid reset token",
Error: "missing or invalid X-Seed-Reset-Token",
})
}
}
seedDir, err := resolveSeedDir(h.Cfg.DBSeedDir)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to resolve seed directory",
Error: err.Error(),
})
}
seedCandidates := []string{
filepath.Join(seedDir, "007_course_management_seed.sql"),
filepath.Join(seedDir, "001_initial_seed_data.sql"),
}
tableNames := []string{"course_categories", "courses", "sub_courses"}
statements := map[string]string{}
statementSource := map[string]string{}
for _, file := range seedCandidates {
contentBytes, readErr := os.ReadFile(file)
if readErr != nil {
continue
}
content := string(contentBytes)
for _, tableName := range tableNames {
if _, exists := statements[tableName]; exists {
continue
}
if stmt, ok := extractInsertStatement(content, tableName); ok {
statements[tableName] = stmt
statementSource[tableName] = file
}
}
}
for _, tableName := range tableNames {
if _, ok := statements[tableName]; !ok {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Missing required seed statement",
Error: fmt.Sprintf("could not find INSERT INTO %s in seed files", tableName),
})
}
}
var sqlBuilder strings.Builder
sqlBuilder.WriteString("BEGIN;\n")
sqlBuilder.WriteString("TRUNCATE TABLE sub_courses, courses, course_categories RESTART IDENTITY CASCADE;\n")
for _, tableName := range tableNames {
sqlBuilder.WriteString("\n-- ")
sqlBuilder.WriteString(tableName)
sqlBuilder.WriteString(" from ")
sqlBuilder.WriteString(statementSource[tableName])
sqlBuilder.WriteString("\n")
sqlBuilder.WriteString(statements[tableName])
sqlBuilder.WriteString("\n")
}
sqlBuilder.WriteString("COMMIT;")
if _, err := h.analyticsDB.ExecRaw(c.Context(), sqlBuilder.String()); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to reset and reseed database",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Course management hierarchy reset and reseed completed successfully",
Data: map[string]interface{}{
"seed_dir": seedDir,
"tables": tableNames,
"sources": statementSource,
},
Success: true,
StatusCode: fiber.StatusOK,
})
}