readysite / hosting / controllers / sites.go
13.5 KB
sites.go
package controllers

import (
	"crypto/rand"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net/http"
	"regexp"
	"strings"
	"time"

	"github.com/golang-jwt/jwt/v5"
	"github.com/readysite/readysite/hosting/internal/access"
	"github.com/readysite/readysite/hosting/internal/payments"
	"github.com/readysite/readysite/hosting/internal/websites"
	"github.com/readysite/readysite/hosting/models"
	"github.com/readysite/readysite/pkg/application"
	"github.com/readysite/readysite/pkg/database"
)

// Sites returns the sites controller.
func Sites() (string, *SitesController) {
	return "sites", &SitesController{}
}

// SitesController handles the sites page and JSON API endpoints.
type SitesController struct {
	application.BaseController
}

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

	// Pages
	http.Handle("GET /sites", app.Serve("sites.html", RequireAuth))
	http.Handle("GET /sites/{id}", app.Method(c, "RedirectToSites", RequireAuth))

	// API
	http.Handle("GET /api/sites", app.Method(c, "ListSites", RequireAuthAPI))
	http.Handle("POST /api/sites", app.Method(c, "CreateSite", RequireAuthAPI))
	http.Handle("GET /api/sites/{id}", app.Method(c, "GetSite", RequireAuthAPI))
	http.Handle("DELETE /api/sites/{id}", app.Method(c, "DeleteSite", RequireAuthAPI))
	http.Handle("GET /api/sites/{id}/events", app.Method(c, "SiteEvents", RequireAuthAPI))
	http.Handle("PATCH /api/sites/{id}", app.Method(c, "UpdateSite", RequireAuthAPI))
	http.Handle("POST /api/sites/{id}/upgrade", app.Method(c, "UpgradeSite", RequireAuthAPI))
	http.Handle("POST /api/sites/{id}/restart", app.Method(c, "RestartSite", RequireAuthAPI))
	http.Handle("GET /api/sites/{id}/admin", app.Method(c, "AdminAccess", RequireAuth))

	go websites.RecoverLaunching()
}

// Handle returns a request-scoped controller instance.
func (c SitesController) Handle(r *http.Request) application.Controller {
	c.Request = r
	return &c
}

// RedirectToSites redirects /sites/{id} to /sites?id={id}.
func (c *SitesController) RedirectToSites(w http.ResponseWriter, r *http.Request) {
	http.Redirect(w, r, "/sites?id="+r.PathValue("id"), http.StatusSeeOther)
}

// --- API Methods ---

// ListSites returns all sites for the current user, with data size for Pro sites.
func (c *SitesController) ListSites(w http.ResponseWriter, r *http.Request) {
	user := access.GetUserFromJWT(r)
	sites, err := models.Sites.Search("WHERE UserID = ? AND Status != 'deleted' ORDER BY CreatedAt DESC", user.ID)
	if err != nil {
		jsonError(w, "Failed to list sites", http.StatusInternalServerError)
		return
	}

	type siteWithData struct {
		*models.Site
		DataSize int64 `json:"DataSize"`
	}
	result := make([]siteWithData, len(sites))
	for i, site := range sites {
		var size int64
		if site.IsPro() {
			size = websites.DataSize(site.ID)
		}
		result[i] = siteWithData{Site: site, DataSize: size}
	}
	jsonResponse(w, result)
}

// CreateSite creates a new site.
func (c *SitesController) CreateSite(w http.ResponseWriter, r *http.Request) {
	user := access.GetUserFromJWT(r)

	var input struct {
		Name        string `json:"name"`
		Description string `json:"description"`
	}

	// Support both JSON and form data
	if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
		if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
			jsonError(w, "Invalid request body", http.StatusBadRequest)
			return
		}
	} else {
		input.Name = r.FormValue("name")
		input.Description = r.FormValue("description")
	}

	if input.Name == "" {
		input.Name = "My Site"
	}

	// Enforce site limit per user
	siteCount := models.Sites.Count("WHERE UserID = ? AND Status != 'deleted'", user.ID)
	if siteCount >= 25 {
		jsonError(w, "Site limit reached (25 sites maximum)", http.StatusBadRequest)
		return
	}

	// Validate site name length
	if len(input.Name) > 200 {
		input.Name = input.Name[:200]
	}

	// Generate slug ID from name
	slug := toSlug(input.Name)
	id := slug
	found := false
	for i := 2; i < 100; i++ {
		existing, err := models.Sites.Get(id)
		if err != nil && !errors.Is(err, database.ErrNotFound) {
			log.Printf("[sites] CreateSite slug check failed: %v", err)
			jsonError(w, "Failed to create site", http.StatusInternalServerError)
			return
		}
		if existing == nil {
			found = true
			break
		}
		id = fmt.Sprintf("%s-%d", slug, i)
	}
	if !found {
		jsonError(w, "Could not generate a unique site ID. Try a different name.", http.StatusConflict)
		return
	}

	// Validate description length
	if len(input.Description) > 500 {
		input.Description = input.Description[:500]
	}

	// Generate AUTH_SECRET for the site's website container
	secretBytes := make([]byte, 32)
	if _, err := rand.Read(secretBytes); err != nil {
		log.Printf("[sites] CreateSite rand.Read failed: %v", err)
		jsonError(w, "Failed to create site", http.StatusInternalServerError)
		return
	}
	authSecret := base64.StdEncoding.EncodeToString(secretBytes)

	site := &models.Site{
		UserID:      user.ID,
		Name:        input.Name,
		Description: input.Description,
		Plan:        "free",
		Status:      "launching",
		AuthSecret:  authSecret,
	}
	site.ID = id
	if _, err := models.Sites.Insert(site); err != nil {
		log.Printf("[sites] CreateSite insert failed: %v", err)
		jsonError(w, "Failed to create site", http.StatusInternalServerError)
		return
	}
	go websites.Launch(site)

	// If HTMX request, redirect to sites page
	if r.Header.Get("HX-Request") == "true" {
		w.Header().Set("HX-Redirect", "/sites?id="+site.ID)
		return
	}

	w.WriteHeader(http.StatusCreated)
	jsonResponse(w, site)
}

// GetSite returns a single site with user info.
func (c *SitesController) GetSite(w http.ResponseWriter, r *http.Request) {
	user := access.GetUserFromJWT(r)
	site, err := models.Sites.Get(r.PathValue("id"))
	if err != nil || site == nil || site.UserID != user.ID {
		jsonError(w, "Site not found", http.StatusNotFound)
		return
	}

	jsonResponse(w, map[string]any{
		"site": site,
		"user": map[string]string{
			"email": user.Email,
			"name":  user.Name,
		},
	})
}

// UpdateSite updates a site's mutable fields (name, description).
func (c *SitesController) UpdateSite(w http.ResponseWriter, r *http.Request) {
	user := access.GetUserFromJWT(r)
	site, err := models.Sites.Get(r.PathValue("id"))
	if err != nil || site == nil || site.UserID != user.ID {
		jsonError(w, "Site not found", http.StatusNotFound)
		return
	}

	var input struct {
		Name        *string `json:"name"`
		Description *string `json:"description"`
	}
	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
		jsonError(w, "Invalid request body", http.StatusBadRequest)
		return
	}

	if input.Name != nil {
		name := strings.TrimSpace(*input.Name)
		if name == "" {
			jsonError(w, "Name cannot be empty", http.StatusBadRequest)
			return
		}
		site.Name = name
	}
	if input.Description != nil {
		desc := *input.Description
		if len(desc) > 500 {
			desc = desc[:500]
		}
		site.Description = desc
	}

	if err := models.Sites.Update(site); err != nil {
		log.Printf("[sites] UpdateSite failed: %v", err)
		jsonError(w, "Failed to update site", http.StatusInternalServerError)
		return
	}
	jsonResponse(w, site)
}

// DeleteSite shuts down or permanently deletes a site.
//   - Active Pro: stops container (preserves data volume), status -> "shutdown"
//   - Active Free: destroys infrastructure (container + caddy), status -> "shutdown"
//   - Shutdown Free: permanently deletes the database record
func (c *SitesController) DeleteSite(w http.ResponseWriter, r *http.Request) {
	user := access.GetUserFromJWT(r)
	site, err := models.Sites.Get(r.PathValue("id"))
	if err != nil || site == nil || site.UserID != user.ID {
		jsonError(w, "Site not found", http.StatusNotFound)
		return
	}

	// Already shutdown free site — permanently delete the record
	if site.Status == "shutdown" && !site.IsPro() {
		site.Status = "deleted"
		if err := models.Sites.Update(site); err != nil {
			log.Printf("[sites] DeleteSite permanent delete failed for %s: %v", site.ID, err)
			jsonError(w, "Failed to delete site", http.StatusInternalServerError)
			return
		}
		log.Printf("[sites] Permanently deleted site %s", site.ID)
		jsonResponse(w, map[string]string{"status": "deleted"})
		return
	}

	if site.IsPro() {
		if err := websites.Stop(site); err != nil {
			log.Printf("[sites] DeleteSite stop failed for %s: %v", site.ID, err)
		}
	} else {
		if err := websites.Destroy(site); err != nil {
			log.Printf("[sites] DeleteSite destroy failed for %s: %v", site.ID, err)
		}
	}

	site.Status = "shutdown"
	if err := models.Sites.Update(site); err != nil {
		log.Printf("[sites] DeleteSite update failed: %v", err)
		jsonError(w, "Failed to delete site", http.StatusInternalServerError)
		return
	}
	jsonResponse(w, map[string]string{"status": "shutdown"})
}

// RestartSite restarts a shutdown Pro site.
func (c *SitesController) RestartSite(w http.ResponseWriter, r *http.Request) {
	user := access.GetUserFromJWT(r)
	site, err := models.Sites.Get(r.PathValue("id"))
	if err != nil || site == nil || site.UserID != user.ID {
		jsonError(w, "Site not found", http.StatusNotFound)
		return
	}

	if !site.IsPro() {
		jsonError(w, "Only Pro sites can be restarted", http.StatusBadRequest)
		return
	}

	if site.Status != "shutdown" {
		jsonError(w, "Site must be shutdown to restart", http.StatusBadRequest)
		return
	}

	if err := websites.Start(site); err != nil {
		log.Printf("[sites] RestartSite failed for %s: %v", site.ID, err)
		jsonError(w, "Failed to restart site", http.StatusInternalServerError)
		return
	}

	site.Status = "active"
	if err := models.Sites.Update(site); err != nil {
		log.Printf("[sites] RestartSite update failed: %v", err)
		jsonError(w, "Failed to update site status", http.StatusInternalServerError)
		return
	}
	jsonResponse(w, site)
}

// AdminAccess generates a short-lived JWT and redirects to the site's /auth/token endpoint,
// signing the user in as admin without needing a password.
func (c *SitesController) AdminAccess(w http.ResponseWriter, r *http.Request) {
	user := access.GetUserFromJWT(r)
	site, err := models.Sites.Get(r.PathValue("id"))
	if err != nil || site == nil || site.UserID != user.ID {
		http.Error(w, "Site not found", http.StatusNotFound)
		return
	}

	// Lazily generate AuthSecret for sites created before this feature
	if site.AuthSecret == "" {
		b := make([]byte, 32)
		if _, err := rand.Read(b); err != nil {
			log.Printf("[sites] AdminAccess rand.Read failed: %v", err)
			http.Error(w, "Failed to generate access token", http.StatusInternalServerError)
			return
		}
		site.AuthSecret = base64.StdEncoding.EncodeToString(b)
		if err := models.Sites.Update(site); err != nil {
			log.Printf("[sites] AdminAccess update AuthSecret failed: %v", err)
			http.Error(w, "Failed to generate access token", http.StatusInternalServerError)
			return
		}
	}

	// Generate short-lived JWT with the user's email
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		"email": user.Email,
		"exp":   time.Now().Add(5 * time.Minute).Unix(),
	})
	tokenString, err := token.SignedString([]byte(site.AuthSecret))
	if err != nil {
		log.Printf("[sites] AdminAccess JWT sign failed for %s: %v", site.ID, err)
		http.Error(w, "Failed to generate access token", http.StatusInternalServerError)
		return
	}

	siteURL := fmt.Sprintf("https://%s%s/auth/token?token=%s", site.ID, websites.DomainSuffix, tokenString)
	http.Redirect(w, r, siteURL, http.StatusSeeOther)
}

// UpgradeSite upgrades a site to Pro plan, triggering dedicated server provisioning.
func (c *SitesController) UpgradeSite(w http.ResponseWriter, r *http.Request) {
	user := access.GetUserFromJWT(r)
	site, err := models.Sites.Get(r.PathValue("id"))
	if err != nil || site == nil || site.UserID != user.ID {
		jsonError(w, "Site not found", http.StatusNotFound)
		return
	}

	if site.IsPro() {
		jsonError(w, "Site is already on Pro plan", http.StatusBadRequest)
		return
	}

	if site.Status != "active" {
		jsonError(w, "Site must be active to upgrade", http.StatusBadRequest)
		return
	}

	if err := payments.UpgradeSite(site); err != nil {
		log.Printf("[sites] UpgradeSite %s failed: %v", site.ID, err)
		jsonError(w, "Upgrade failed. Please try again.", http.StatusInternalServerError)
		return
	}

	jsonResponse(w, site)
}

// SiteEvents streams real-time status updates for a site via SSE.
func (c *SitesController) SiteEvents(w http.ResponseWriter, r *http.Request) {
	user := access.GetUserFromJWT(r)
	site, err := models.Sites.Get(r.PathValue("id"))
	if err != nil || site == nil || site.UserID != user.ID {
		jsonError(w, "Site not found", http.StatusNotFound)
		return
	}

	stream := c.Stream(w)
	ch := websites.Subscribe(site.ID)
	defer websites.Unsubscribe(site.ID, ch)

	// Send current status immediately
	if data, err := json.Marshal(websites.Event{Status: site.Status}); err == nil {
		stream.Send("status", string(data))
	}

	for {
		select {
		case <-r.Context().Done():
			return
		case event, ok := <-ch:
			if !ok {
				return
			}
			if data, err := json.Marshal(event); err == nil {
				stream.Send("status", string(data))
			}
		}
	}
}

// --- Helpers ---

func jsonResponse(w http.ResponseWriter, data any) {
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(data)
}

func jsonError(w http.ResponseWriter, message string, status int) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(map[string]string{"error": message})
}

// --- Slug helper ---

var nonAlphanumeric = regexp.MustCompile(`[^a-z0-9]+`)

func toSlug(name string) string {
	s := strings.ToLower(strings.TrimSpace(name))
	s = nonAlphanumeric.ReplaceAllString(s, "-")
	s = strings.Trim(s, "-")
	if len(s) > 30 {
		s = s[:30]
	}
	if s == "" {
		s = "site"
	}
	return s
}
← Back