readysite / website / controllers / setup.go
5.4 KB
setup.go
package controllers

import (
	"fmt"
	"net/http"

	"github.com/readysite/readysite/pkg/application"
	"github.com/readysite/readysite/website/internal/access"
	"github.com/readysite/readysite/website/internal/helpers"
	"github.com/readysite/readysite/website/models"
	"golang.org/x/crypto/bcrypt"
)

// Setup returns the setup wizard controller.
func Setup() (string, *SetupController) {
	return "setup", &SetupController{}
}

// SetupController handles first-run setup wizard.
type SetupController struct {
	application.BaseController
}

// Setup registers routes.
func (c *SetupController) Setup(app *application.App) {
	c.BaseController.Setup(app)

	// All setup routes should redirect to dashboard if setup is complete
	http.Handle("GET /setup", app.Serve("setup/index.html", c.requireSetupIncomplete))
	http.Handle("GET /setup/account", app.Serve("setup/account.html", c.requireSetupIncomplete))
	http.Handle("POST /setup/account", app.Method(c, "CreateAccount", c.requireSetupIncomplete))
	http.Handle("GET /setup/ai", app.Serve("setup/ai.html", c.requireAccount))
	http.Handle("POST /setup/ai", app.Method(c, "ConfigureAI", c.requireAccount))
	http.Handle("GET /setup/complete", app.Serve("setup/complete.html", c.requireAccount))
	http.Handle("POST /setup/complete", app.Method(c, "Complete", c.requireAccount))
}

// Handle implements Controller interface with value receiver for request isolation.
func (c SetupController) Handle(r *http.Request) application.Controller {
	c.Request = r
	return &c
}

// requireSetupIncomplete redirects to dashboard if setup is already complete.
func (c *SetupController) requireSetupIncomplete(app *application.App, w http.ResponseWriter, r *http.Request) bool {
	if !c.NeedsSetup() {
		http.Redirect(w, r, "/admin", http.StatusSeeOther)
		return false
	}
	return true
}

// requireAccount redirects to /setup if no admin account exists yet.
func (c *SetupController) requireAccount(app *application.App, w http.ResponseWriter, r *http.Request) bool {
	if !c.requireSetupIncomplete(app, w, r) {
		return false
	}
	if models.Users.Count("WHERE Role = ?", access.RoleAdmin) == 0 {
		http.Redirect(w, r, "/setup", http.StatusSeeOther)
		return false
	}
	return true
}

// NeedsSetup returns true if the first-run setup hasn't been completed.
func (c *SetupController) NeedsSetup() bool {
	// Check if setup has been marked complete
	// This allows the multi-step wizard to work (account -> ai -> complete)
	return helpers.GetSetting(models.SettingSetupComplete) != "true"
}

// Step returns the current setup step based on the URL path.
func (c *SetupController) Step() string {
	if c.Request == nil {
		return "account"
	}
	path := c.URL.Path
	switch path {
	case "/setup/ai":
		return "ai"
	case "/setup/complete":
		return "complete"
	default:
		return "account"
	}
}

// CreateAccount creates the admin account and signs them in.
func (c *SetupController) CreateAccount(w http.ResponseWriter, r *http.Request) {
	// SECURITY: Check again that no admin exists to prevent race condition
	// where two concurrent requests both pass the bouncer check
	if models.Users.Count("WHERE Role = ?", access.RoleAdmin) > 0 {
		c.RenderError(w, r, fmt.Errorf("An admin account already exists"))
		return
	}

	email := r.FormValue("email")
	password := r.FormValue("password")
	name := r.FormValue("name")

	if email == "" || password == "" {
		c.RenderError(w, r, fmt.Errorf("Email and password are required"))
		return
	}

	// Check password length
	if len(password) < 8 {
		c.RenderError(w, r, fmt.Errorf("Password must be at least 8 characters"))
		return
	}

	// Hash password
	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	if err != nil {
		c.RenderError(w, r, fmt.Errorf("Failed to hash password: %w", err))
		return
	}

	user := &models.User{
		Email:        email,
		PasswordHash: string(hash),
		Name:         name,
		Role:         access.RoleAdmin,
		Verified:     true,
	}

	if _, err := models.Users.Insert(user); err != nil {
		// Could be a race - another admin was created
		c.RenderError(w, r, fmt.Errorf("Failed to create user: %w", err))
		return
	}

	// Sign in the new user immediately
	access.SetSessionCookie(w, r, user.ID)

	c.Redirect(w, r, "/setup/ai")
}

// ConfigureAI saves AI configuration.
func (c *SetupController) ConfigureAI(w http.ResponseWriter, r *http.Request) {
	provider := r.FormValue("provider")
	apiKey := r.FormValue("api_key")
	model := r.FormValue("model")

	if provider == "" {
		provider = "mock" // Default to mock for testing
	}

	helpers.SetSetting(models.SettingAIProvider, provider)
	if apiKey != "" {
		helpers.SetSetting(models.SettingAIAPIKey, apiKey)
	}
	if model != "" {
		helpers.SetSetting(models.SettingAIModel, model)
	}

	c.Redirect(w, r, "/setup/complete")
}

// Complete marks setup as complete and redirects to dashboard.
func (c *SetupController) Complete(w http.ResponseWriter, r *http.Request) {
	siteName := r.FormValue("site_name")
	siteDescription := r.FormValue("site_description")

	if siteName != "" {
		helpers.SetSetting(models.SettingSiteName, siteName)
	}
	if siteDescription != "" {
		helpers.SetSetting(models.SettingSiteDescription, siteDescription)
	}

	helpers.SetSetting(models.SettingSetupComplete, "true")

	// Auto sign-in the admin user
	admin, err := models.Users.First("WHERE Role = ?", access.RoleAdmin)
	if err == nil && admin != nil {
		access.SetSessionCookie(w, r, admin.ID)
	}

	// Full page redirect (not boosted) since layout changes
	http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
← Back