new reseed endpoint
This commit is contained in:
parent
8f719c2a32
commit
0cb58b35f8
13
gen/db/raw_exec.go
Normal file
13
gen/db/raw_exec.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package dbgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExecRaw executes raw SQL against the underlying sqlc DB connection.
|
||||||
|
// Use this sparingly for operational tasks that are not modelled by sqlc queries.
|
||||||
|
func (q *Queries) ExecRaw(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) {
|
||||||
|
return q.db.Exec(ctx, sql, args...)
|
||||||
|
}
|
||||||
|
|
@ -140,6 +140,9 @@ type Config struct {
|
||||||
AccountDeletionPurgeEnabled bool
|
AccountDeletionPurgeEnabled bool
|
||||||
AccountDeletionPurgeInterval time.Duration
|
AccountDeletionPurgeInterval time.Duration
|
||||||
AccountDeletionPurgeBatchSize int32
|
AccountDeletionPurgeBatchSize int32
|
||||||
|
DBResetReseedEnabled bool
|
||||||
|
DBResetReseedToken string
|
||||||
|
DBSeedDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConfig() (*Config, error) {
|
func NewConfig() (*Config, error) {
|
||||||
|
|
@ -562,6 +565,15 @@ func (c *Config) loadEnv() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dangerous DB reset+reseed endpoint configuration (disabled by default)
|
||||||
|
dbResetReseedEnabled := strings.TrimSpace(os.Getenv("DB_RESET_RESEED_ENABLED"))
|
||||||
|
c.DBResetReseedEnabled = dbResetReseedEnabled == "true" || dbResetReseedEnabled == "1"
|
||||||
|
c.DBResetReseedToken = strings.TrimSpace(os.Getenv("DB_RESET_RESEED_TOKEN"))
|
||||||
|
c.DBSeedDir = strings.TrimSpace(os.Getenv("DB_SEED_DIR"))
|
||||||
|
if c.DBSeedDir == "" {
|
||||||
|
c.DBSeedDir = "db/data"
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,9 @@ var AllPermissions = []domain.PermissionSeed{
|
||||||
{Key: "rbac.permissions.list", Name: "List Permissions", Description: "List all permissions", GroupName: "RBAC"},
|
{Key: "rbac.permissions.list", Name: "List Permissions", Description: "List all permissions", GroupName: "RBAC"},
|
||||||
{Key: "rbac.permissions.groups", Name: "List Permission Groups", Description: "List permission groups", GroupName: "RBAC"},
|
{Key: "rbac.permissions.groups", Name: "List Permission Groups", Description: "List permission groups", GroupName: "RBAC"},
|
||||||
{Key: "rbac.permissions.sync", Name: "Sync Permissions", Description: "Sync permissions from code", GroupName: "RBAC"},
|
{Key: "rbac.permissions.sync", Name: "Sync Permissions", Description: "Sync permissions from code", GroupName: "RBAC"},
|
||||||
|
|
||||||
|
// Internal operations
|
||||||
|
{Key: "internal.db.reset_reseed", Name: "Reset And Reseed Database", Description: "Dangerous operation: clears all data and re-seeds from SQL files", GroupName: "Internal Operations"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultRolePermissions maps each system role to the permission keys it should
|
// DefaultRolePermissions maps each system role to the permission keys it should
|
||||||
|
|
@ -306,6 +309,9 @@ var DefaultRolePermissions = map[string][]string{
|
||||||
"rbac.roles.list", "rbac.roles.get", "rbac.roles.create", "rbac.roles.update", "rbac.roles.delete",
|
"rbac.roles.list", "rbac.roles.get", "rbac.roles.create", "rbac.roles.update", "rbac.roles.delete",
|
||||||
"rbac.roles.set_permissions", "rbac.roles.get_permissions",
|
"rbac.roles.set_permissions", "rbac.roles.get_permissions",
|
||||||
"rbac.permissions.list", "rbac.permissions.groups", "rbac.permissions.sync",
|
"rbac.permissions.list", "rbac.permissions.groups", "rbac.permissions.sync",
|
||||||
|
|
||||||
|
// Internal operations
|
||||||
|
"internal.db.reset_reseed",
|
||||||
},
|
},
|
||||||
|
|
||||||
"STUDENT": {
|
"STUDENT": {
|
||||||
|
|
|
||||||
155
internal/web_server/handlers/maintenance_handler.go
Normal file
155
internal/web_server/handlers/maintenance_handler.go
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"Yimaru-Backend/internal/domain"
|
||||||
|
"crypto/subtle"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type resetAndReseedReq struct {
|
||||||
|
Confirm string `json:"confirm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetAndReseedDatabase godoc
|
||||||
|
// @Summary Reset and reseed database
|
||||||
|
// @Description Dangerous operation: truncates all public tables (except schema_migrations) and reseeds from db/data 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)
|
||||||
|
providedToken := strings.TrimSpace(c.Get("X-Seed-Reset-Token"))
|
||||||
|
if expectedToken == "" || 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 := strings.TrimSpace(h.Cfg.DBSeedDir)
|
||||||
|
if seedDir == "" {
|
||||||
|
seedDir = "db/data"
|
||||||
|
}
|
||||||
|
seedFiles, err := filepath.Glob(filepath.Join(seedDir, "*.sql"))
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Failed to resolve seed files",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(seedFiles) == 0 {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "No seed files found",
|
||||||
|
Error: fmt.Sprintf("no .sql files found in %s", seedDir),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Strings(seedFiles)
|
||||||
|
|
||||||
|
var sqlBuilder strings.Builder
|
||||||
|
sqlBuilder.WriteString("BEGIN;\n")
|
||||||
|
sqlBuilder.WriteString(`
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
truncate_stmt text;
|
||||||
|
BEGIN
|
||||||
|
SELECT 'TRUNCATE TABLE ' || string_agg(format('%I.%I', schemaname, tablename), ', ')
|
||||||
|
|| ' RESTART IDENTITY CASCADE'
|
||||||
|
INTO truncate_stmt
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE schemaname = 'public'
|
||||||
|
AND tablename <> 'schema_migrations';
|
||||||
|
|
||||||
|
IF truncate_stmt IS NOT NULL THEN
|
||||||
|
EXECUTE truncate_stmt;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
for _, file := range seedFiles {
|
||||||
|
content, readErr := os.ReadFile(file)
|
||||||
|
if readErr != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Failed to read seed file",
|
||||||
|
Error: fmt.Sprintf("%s: %v", file, readErr),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sqlBuilder.WriteString("\n-- seed file: ")
|
||||||
|
sqlBuilder.WriteString(file)
|
||||||
|
sqlBuilder.WriteString("\n")
|
||||||
|
sqlBuilder.Write(content)
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure permission definitions and default assignments are in sync after reseed.
|
||||||
|
if err := h.rbacSvc.SeedPermissions(c.Context()); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Database reseeded but RBAC permission sync failed",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := h.rbacSvc.SeedDefaultRolePermissions(c.Context()); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Database reseeded but RBAC role default sync failed",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := h.rbacSvc.Reload(c.Context()); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Database reseeded but RBAC cache reload failed",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(domain.Response{
|
||||||
|
Message: "Database reset and reseed completed successfully",
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"seed_dir": seedDir,
|
||||||
|
"seed_files": seedFiles,
|
||||||
|
"seeded_count": len(seedFiles),
|
||||||
|
},
|
||||||
|
Success: true,
|
||||||
|
StatusCode: fiber.StatusOK,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -244,6 +244,7 @@ func (a *App) initAppRoutes() {
|
||||||
groupV1.Delete("/user/me", a.authMiddleware, a.RequirePermission("users.delete_self"), h.DeleteMyUserAccount)
|
groupV1.Delete("/user/me", a.authMiddleware, a.RequirePermission("users.delete_self"), h.DeleteMyUserAccount)
|
||||||
groupV1.Post("/user/me/deletion/cancel", a.authMiddleware, a.RequirePermission("users.cancel_delete_self"), h.CancelMyUserAccountDeletion)
|
groupV1.Post("/user/me/deletion/cancel", a.authMiddleware, a.RequirePermission("users.cancel_delete_self"), h.CancelMyUserAccountDeletion)
|
||||||
groupV1.Post("/internal/users/purge-due-deletions", a.authMiddleware, a.RequirePermission("users.purge_due_deletions"), h.PurgeDueDeletedUsers)
|
groupV1.Post("/internal/users/purge-due-deletions", a.authMiddleware, a.RequirePermission("users.purge_due_deletions"), h.PurgeDueDeletedUsers)
|
||||||
|
groupV1.Post("/internal/db/reset-reseed", a.authMiddleware, a.RequirePermission("internal.db.reset_reseed"), h.ResetAndReseedDatabase)
|
||||||
groupV1.Get("/user/single/:id", a.authMiddleware, a.RequirePermission("users.get"), h.GetUserByID)
|
groupV1.Get("/user/single/:id", a.authMiddleware, a.RequirePermission("users.get"), h.GetUserByID)
|
||||||
groupV1.Delete("/user/delete/:id", a.authMiddleware, a.RequirePermission("users.delete"), h.DeleteUser)
|
groupV1.Delete("/user/delete/:id", a.authMiddleware, a.RequirePermission("users.delete"), h.DeleteUser)
|
||||||
groupV1.Post("/user/search", a.authMiddleware, a.RequirePermission("users.search"), h.SearchUserByNameOrPhone)
|
groupV1.Post("/user/search", a.authMiddleware, a.RequirePermission("users.search"), h.SearchUserByNameOrPhone)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user