339 lines
8.2 KiB
Go
339 lines
8.2 KiB
Go
package reseed
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"html"
|
|
"log"
|
|
"strings"
|
|
"time"
|
|
|
|
"boostai-backend/internal/config"
|
|
"boostai-backend/internal/database"
|
|
"boostai-backend/internal/seeddata"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
const reseedCookieName = "boostai_reseed_auth"
|
|
|
|
type runFunc func(ctx context.Context, mockDataDir string) (seeddata.Summary, error)
|
|
|
|
type Handler struct {
|
|
cfg *config.Config
|
|
runner runFunc
|
|
}
|
|
|
|
type pageData struct {
|
|
Authorized bool
|
|
Environment string
|
|
MockDataDir string
|
|
Error string
|
|
Success string
|
|
Summary *seeddata.Summary
|
|
}
|
|
|
|
func NewHandler(db *database.DB, cfg *config.Config) *Handler {
|
|
return &Handler{
|
|
cfg: cfg,
|
|
runner: func(ctx context.Context, mockDataDir string) (seeddata.Summary, error) {
|
|
return seeddata.Run(ctx, db, mockDataDir)
|
|
},
|
|
}
|
|
}
|
|
|
|
func (h *Handler) Page(c *fiber.Ctx) error {
|
|
if !h.cfg.AdminReseedEnabled {
|
|
return fiber.ErrNotFound
|
|
}
|
|
|
|
return h.renderPage(c, pageData{
|
|
Authorized: h.isAuthorized(c),
|
|
Environment: h.cfg.Environment,
|
|
MockDataDir: h.cfg.MockDataDir,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) Login(c *fiber.Ctx) error {
|
|
if !h.cfg.AdminReseedEnabled {
|
|
return fiber.ErrNotFound
|
|
}
|
|
|
|
password := strings.TrimSpace(c.FormValue("password"))
|
|
if subtle.ConstantTimeCompare([]byte(password), []byte(h.cfg.ReseedPagePassword)) != 1 {
|
|
return h.renderPage(c.Status(fiber.StatusUnauthorized), pageData{
|
|
Authorized: false,
|
|
Environment: h.cfg.Environment,
|
|
MockDataDir: h.cfg.MockDataDir,
|
|
Error: "Invalid password",
|
|
})
|
|
}
|
|
|
|
h.setAuthCookie(c)
|
|
return c.Redirect("/reseed", fiber.StatusSeeOther)
|
|
}
|
|
|
|
func (h *Handler) Run(c *fiber.Ctx) error {
|
|
if !h.cfg.AdminReseedEnabled {
|
|
return fiber.ErrNotFound
|
|
}
|
|
|
|
if !h.isAuthorized(c) {
|
|
return h.renderPage(c.Status(fiber.StatusUnauthorized), pageData{
|
|
Authorized: false,
|
|
Environment: h.cfg.Environment,
|
|
MockDataDir: h.cfg.MockDataDir,
|
|
Error: "Please unlock the reseed page first",
|
|
})
|
|
}
|
|
|
|
startedAt := time.Now().UTC()
|
|
log.Printf("browser reseed requested environment=%s ip=%s", h.cfg.Environment, c.IP())
|
|
|
|
timeoutCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
|
|
summary, err := h.runner(timeoutCtx, h.cfg.MockDataDir)
|
|
if err != nil {
|
|
log.Printf("browser reseed failed environment=%s ip=%s err=%v", h.cfg.Environment, c.IP(), err)
|
|
return h.renderPage(c.Status(fiber.StatusInternalServerError), pageData{
|
|
Authorized: true,
|
|
Environment: h.cfg.Environment,
|
|
MockDataDir: h.cfg.MockDataDir,
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
log.Printf("browser reseed completed environment=%s ip=%s users=%d assignments=%d student_answers=%d", h.cfg.Environment, c.IP(), summary.Users, summary.Assignments, summary.StudentAnswers)
|
|
|
|
return h.renderPage(c, pageData{
|
|
Authorized: true,
|
|
Environment: h.cfg.Environment,
|
|
MockDataDir: h.cfg.MockDataDir,
|
|
Success: fmt.Sprintf("Reseed completed at %s UTC", startedAt.Format("2006-01-02 15:04:05")),
|
|
Summary: &summary,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) Logout(c *fiber.Ctx) error {
|
|
if !h.cfg.AdminReseedEnabled {
|
|
return fiber.ErrNotFound
|
|
}
|
|
|
|
h.clearAuthCookie(c)
|
|
return c.Redirect("/reseed", fiber.StatusSeeOther)
|
|
}
|
|
|
|
func (h *Handler) isAuthorized(c *fiber.Ctx) bool {
|
|
provided := strings.TrimSpace(c.Cookies(reseedCookieName))
|
|
expected := h.authCookieValue()
|
|
if provided == "" || expected == "" {
|
|
return false
|
|
}
|
|
|
|
return subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1
|
|
}
|
|
|
|
func (h *Handler) setAuthCookie(c *fiber.Ctx) {
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: reseedCookieName,
|
|
Value: h.authCookieValue(),
|
|
HTTPOnly: true,
|
|
Secure: h.cfg.IsProduction(),
|
|
SameSite: fiber.CookieSameSiteLaxMode,
|
|
Path: "/",
|
|
Expires: time.Now().UTC().Add(12 * time.Hour),
|
|
})
|
|
}
|
|
|
|
func (h *Handler) clearAuthCookie(c *fiber.Ctx) {
|
|
c.Cookie(&fiber.Cookie{
|
|
Name: reseedCookieName,
|
|
Value: "",
|
|
HTTPOnly: true,
|
|
Secure: h.cfg.IsProduction(),
|
|
SameSite: fiber.CookieSameSiteLaxMode,
|
|
Path: "/",
|
|
Expires: time.Unix(0, 0),
|
|
MaxAge: -1,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) authCookieValue() string {
|
|
sum := sha256.Sum256([]byte(h.cfg.JWTSecret + "|" + h.cfg.ReseedPagePassword))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func (h *Handler) renderPage(c *fiber.Ctx, data pageData) error {
|
|
content := reseedPageHTML(data)
|
|
c.Type("html", "utf-8")
|
|
return c.SendString(content)
|
|
}
|
|
|
|
func reseedPageHTML(data pageData) string {
|
|
var statusHTML strings.Builder
|
|
if data.Error != "" {
|
|
statusHTML.WriteString(`<div class="notice notice-error">` + html.EscapeString(data.Error) + `</div>`)
|
|
}
|
|
if data.Success != "" {
|
|
statusHTML.WriteString(`<div class="notice notice-success">` + html.EscapeString(data.Success) + `</div>`)
|
|
}
|
|
if data.Summary != nil {
|
|
statusHTML.WriteString(`<pre class="summary">`)
|
|
statusHTML.WriteString(html.EscapeString(fmt.Sprintf(
|
|
"users: %d\nclassrooms: %d\nquestions: %d\ntags: %d\nassignments: %d\nassignment_links: %d\nstudent_answers: %d\nmock_data_dir: %s",
|
|
data.Summary.Users,
|
|
data.Summary.Classrooms,
|
|
data.Summary.Questions,
|
|
data.Summary.Tags,
|
|
data.Summary.Assignments,
|
|
data.Summary.AssignmentLinks,
|
|
data.Summary.StudentAnswers,
|
|
data.Summary.MockDataDir,
|
|
)))
|
|
statusHTML.WriteString(`</pre>`)
|
|
}
|
|
|
|
var body strings.Builder
|
|
if data.Authorized {
|
|
body.WriteString(`
|
|
<div class="card">
|
|
<h2>Reseed database</h2>
|
|
<p>This will clear seeded app data and repopulate it from Mock-Data.</p>
|
|
<form method="post" action="/reseed/run">
|
|
<button class="danger" type="submit">Reseed now</button>
|
|
</form>
|
|
<form method="post" action="/reseed/logout">
|
|
<button type="submit">Lock page</button>
|
|
</form>
|
|
</div>
|
|
`)
|
|
} else {
|
|
body.WriteString(`
|
|
<div class="card">
|
|
<h2>Unlock reseed</h2>
|
|
<form method="post" action="/reseed/login">
|
|
<label for="password">Password</label>
|
|
<input id="password" name="password" type="password" autocomplete="current-password" required />
|
|
<button type="submit">Unlock</button>
|
|
</form>
|
|
</div>
|
|
`)
|
|
}
|
|
|
|
return fmt.Sprintf(`<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>BoostAI Reseed</title>
|
|
<style>
|
|
:root { color-scheme: dark; }
|
|
body {
|
|
margin: 0;
|
|
font-family: Inter, Arial, sans-serif;
|
|
background: #0f172a;
|
|
color: #e2e8f0;
|
|
}
|
|
main {
|
|
max-width: 720px;
|
|
margin: 48px auto;
|
|
padding: 0 20px 48px;
|
|
}
|
|
h1, h2 { margin-top: 0; }
|
|
.card {
|
|
background: #111827;
|
|
border: 1px solid #334155;
|
|
border-radius: 16px;
|
|
padding: 24px;
|
|
margin-bottom: 20px;
|
|
box-shadow: 0 12px 30px rgba(0,0,0,0.25);
|
|
}
|
|
.meta {
|
|
display: grid;
|
|
grid-template-columns: 180px 1fr;
|
|
gap: 8px 12px;
|
|
font-size: 14px;
|
|
margin-bottom: 20px;
|
|
}
|
|
.meta strong { color: #93c5fd; }
|
|
label {
|
|
display: block;
|
|
margin-bottom: 8px;
|
|
font-weight: 600;
|
|
}
|
|
input {
|
|
width: 100%%;
|
|
box-sizing: border-box;
|
|
padding: 12px 14px;
|
|
border-radius: 10px;
|
|
border: 1px solid #475569;
|
|
background: #0f172a;
|
|
color: #e2e8f0;
|
|
margin-bottom: 14px;
|
|
}
|
|
button {
|
|
padding: 12px 16px;
|
|
border-radius: 10px;
|
|
border: 0;
|
|
cursor: pointer;
|
|
font-weight: 700;
|
|
background: #38bdf8;
|
|
color: #082f49;
|
|
margin-right: 12px;
|
|
margin-bottom: 12px;
|
|
}
|
|
button.danger {
|
|
background: #f87171;
|
|
color: #450a0a;
|
|
}
|
|
.notice {
|
|
padding: 14px 16px;
|
|
border-radius: 12px;
|
|
margin-bottom: 16px;
|
|
font-weight: 600;
|
|
}
|
|
.notice-error {
|
|
background: rgba(220, 38, 38, 0.18);
|
|
border: 1px solid rgba(248, 113, 113, 0.45);
|
|
color: #fecaca;
|
|
}
|
|
.notice-success {
|
|
background: rgba(22, 163, 74, 0.18);
|
|
border: 1px solid rgba(74, 222, 128, 0.45);
|
|
color: #bbf7d0;
|
|
}
|
|
.summary {
|
|
background: #020617;
|
|
border: 1px solid #334155;
|
|
border-radius: 12px;
|
|
padding: 16px;
|
|
overflow: auto;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<div class="card">
|
|
<h1>BoostAI reseed</h1>
|
|
<div class="meta">
|
|
<strong>Environment</strong><span>%s</span>
|
|
<strong>Mock data path</strong><span>%s</span>
|
|
<strong>Mode</strong><span>Browser-protected destructive reseed</span>
|
|
</div>
|
|
%s
|
|
</div>
|
|
%s
|
|
</main>
|
|
</body>
|
|
</html>`,
|
|
html.EscapeString(data.Environment),
|
|
html.EscapeString(data.MockDataDir),
|
|
statusHTML.String(),
|
|
body.String(),
|
|
)
|
|
}
|