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
}