Files
member-console/internal/server/fedwiki_partials.go
Christian Galo 675a4d93a3 Buffer template rendering and fix FedWiki sync
Introduce SafeTemplates.Render to execute templates into a buffer and
prevent partial HTML on errors. Replace direct ExecuteTemplate calls in
partial handlers and add a make lint-templates target to catch bypasses.
Update operator sites template/view model to use OwnerOrgName. Guard the
FedWiki sync by skipping inserts when DefaultWorkspaceID is empty and
scope deletes to the configured default workspace only.
2026-03-29 04:58:02 -05:00

481 lines
14 KiB
Go

package server
import (
"context"
"database/sql"
"html/template"
"io/fs"
"log/slog"
"net/http"
"strings"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/fedwiki"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/fedwiki"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/queues"
"github.com/google/uuid"
"go.temporal.io/sdk/client"
)
// FedWikiPartialsHandler handles HTMX partial requests for FedWiki management.
type FedWikiPartialsHandler struct {
SiteQ fwmod.Querier
EntitlementsQ entitlements.Querier
Database *sql.DB
Logger *slog.Logger
TemporalClient client.Client
AuthConfig *auth.Config
FedWikiAllowedDomains []string // Domains where users can create sites
FedWikiSiteScheme string // http or https
SupportURL string
Templates *SafeTemplates
}
// FedWikiPartialsConfig holds configuration for the FedWiki partials handler.
type FedWikiPartialsConfig struct {
SiteQ fwmod.Querier
EntitlementsQ entitlements.Querier
Database *sql.DB
Logger *slog.Logger
TemporalClient client.Client
AuthConfig *auth.Config
FedWikiAllowedDomains []string
FedWikiSiteScheme string
SupportURL string
}
// NewFedWikiPartialsHandler creates a new FedWikiPartialsHandler.
func NewFedWikiPartialsHandler(cfg FedWikiPartialsConfig) (*FedWikiPartialsHandler, error) {
// Parse partial templates
templateSubFS, err := fs.Sub(embeds.Templates, "templates/partials")
if err != nil {
return nil, err
}
tmpl, err := template.ParseFS(templateSubFS, "*.html")
if err != nil {
return nil, err
}
return &FedWikiPartialsHandler{
SiteQ: cfg.SiteQ,
EntitlementsQ: cfg.EntitlementsQ,
Database: cfg.Database,
Logger: cfg.Logger,
TemporalClient: cfg.TemporalClient,
AuthConfig: cfg.AuthConfig,
FedWikiAllowedDomains: cfg.FedWikiAllowedDomains,
FedWikiSiteScheme: cfg.FedWikiSiteScheme,
SupportURL: cfg.SupportURL,
Templates: NewSafeTemplates(tmpl, cfg.Logger),
}, nil
}
// RegisterRoutes registers all HTMX partial routes.
func (h *FedWikiPartialsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /partials/fedwiki/sites", h.GetSites)
mux.HandleFunc("GET /partials/fedwiki/create-form", h.GetCreateForm)
mux.HandleFunc("POST /partials/fedwiki/sites", h.CreateSite)
mux.HandleFunc("GET /partials/fedwiki/delete-confirm/{domain}", h.GetDeleteConfirm)
mux.HandleFunc("DELETE /partials/fedwiki/sites/{domain}", h.DeleteSite)
}
// SiteViewModel represents a site for template rendering.
type SiteViewModel struct {
ID string
Domain string // Full domain as stored in database
IsCustomDomain bool
CreatedAt string
URL string
}
// SitesData holds data for the sites list partial.
type SitesData struct {
Sites []SiteViewModel
CurrentCount int64
Quota int64
CanCreate bool
HasEntitlement bool
Error string
}
// GetSites handles GET /partials/fedwiki/sites
func (h *FedWikiPartialsHandler) GetSites(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
h.renderError(w, "fedwiki_sites.html", "Unauthorized")
return
}
workspaceID := session.WorkspaceID
// Get sites by workspace
sites, err := h.SiteQ.ListSitesByWorkspace(r.Context(), workspaceID)
if err != nil {
h.Logger.Error("failed to get user sites", slog.Any("error", err))
h.renderError(w, "fedwiki_sites.html", "Failed to retrieve sites")
return
}
// Build view models
siteVMs := make([]SiteViewModel, len(sites))
for i, site := range sites {
siteVMs[i] = SiteViewModel{
ID: site.SiteID,
Domain: site.Domain,
IsCustomDomain: site.IsCustomDomain,
CreatedAt: site.CreatedAt.Format("Jan 2, 2006"),
URL: h.buildSiteURL(site.Domain, site.IsCustomDomain),
}
}
// Get quota from entitlements
count := int64(len(sites))
var quota int64
canCreate := false
hasEntitlement := false
q, err := h.getWorkspaceQuota(r.Context(), workspaceID)
if err == nil {
hasEntitlement = true
quota = q.resourceLimit
canCreate = q.currentUsage < q.resourceLimit
count = q.currentUsage
}
data := SitesData{
Sites: siteVMs,
CurrentCount: count,
Quota: quota,
CanCreate: canCreate,
HasEntitlement: hasEntitlement,
}
h.Templates.Render(w, "fedwiki_sites.html", data)
}
// CreateFormData holds data for the create form partial.
type CreateFormData struct {
AllowedDomains []string // All domains where users can create sites
CanCreate bool
CurrentCount int64
Quota int64
Error string
}
// GetCreateForm handles GET /partials/fedwiki/create-form
func (h *FedWikiPartialsHandler) GetCreateForm(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
workspaceID := session.WorkspaceID
var count int64
var quota int64
canCreate := false
q, err := h.getWorkspaceQuota(r.Context(), workspaceID)
if err == nil {
quota = q.resourceLimit
canCreate = q.currentUsage < q.resourceLimit
count = q.currentUsage
}
data := CreateFormData{
AllowedDomains: h.FedWikiAllowedDomains,
CanCreate: canCreate,
CurrentCount: count,
Quota: quota,
}
h.Templates.Render(w, "fedwiki_create_form.html", data)
}
// CreateSite handles POST /partials/fedwiki/sites
func (h *FedWikiPartialsHandler) CreateSite(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
h.Logger.Error("failed to get session")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Parse form
if err := r.ParseForm(); err != nil {
h.renderCreateFormError(w, session.WorkspaceID, "Invalid form data")
return
}
domain := strings.TrimSpace(strings.ToLower(r.FormValue("domain")))
selectedDomain := strings.TrimSpace(r.FormValue("selectedDomain"))
isCustomDomain := r.FormValue("isCustomDomain") == "true"
if domain == "" {
h.renderCreateFormError(w, session.WorkspaceID, "Domain is required")
return
}
// Validate domain is a legal DNS label (lowercase enforced by ToLower above)
if errMsg := validateDNSLabel(domain); errMsg != "" {
h.renderCreateFormError(w, session.WorkspaceID, errMsg)
return
}
// Validate selected domain is in allowed list (unless custom domain)
if !isCustomDomain {
if selectedDomain == "" && len(h.FedWikiAllowedDomains) > 0 {
selectedDomain = h.FedWikiAllowedDomains[0]
}
validDomain := false
for _, allowed := range h.FedWikiAllowedDomains {
if selectedDomain == allowed {
validDomain = true
break
}
}
if !validDomain {
h.renderCreateFormError(w, session.WorkspaceID, "Invalid domain selected")
return
}
// Defense-in-depth: check assembled FQDN length (RFC 1035 §2.3.4)
if errMsg := validateFQDN(domain, selectedDomain); errMsg != "" {
h.renderCreateFormError(w, session.WorkspaceID, errMsg)
return
}
}
// Check Temporal client
if h.TemporalClient == nil {
h.Logger.Error("Temporal client not configured")
h.renderCreateFormError(w, session.WorkspaceID, "Site provisioning service is not available")
return
}
// Start the workflow
workflowID := "create-fedwiki-site-" + uuid.New().String()
workflowOptions := client.StartWorkflowOptions{
ID: workflowID,
TaskQueue: queues.Main,
}
input := fedwiki.CreateFedWikiSiteWorkflowInput{
WorkspaceID: session.WorkspaceID,
Domain: domain,
SiteDomain: selectedDomain,
OwnerName: session.Username,
OwnerID: session.OIDCSubject,
IsCustomDomain: isCustomDomain,
SupportURL: h.SupportURL,
}
we, err := h.TemporalClient.ExecuteWorkflow(r.Context(), workflowOptions, fedwiki.CreateFedWikiSiteWorkflow, input)
if err != nil {
h.Logger.Error("failed to start workflow", slog.Any("error", err))
h.renderCreateFormError(w, session.WorkspaceID, "Failed to initiate site creation")
return
}
h.Logger.Info("site creation workflow started",
slog.String("workflowID", we.GetID()),
slog.String("domain", domain),
slog.String("siteDomain", selectedDomain))
// Wait for the workflow to complete
var result fedwiki.CreateFedWikiSiteWorkflowOutput
if err := we.Get(r.Context(), &result); err != nil {
h.Logger.Error("workflow execution failed", slog.Any("error", err))
h.renderCreateFormError(w, session.WorkspaceID, "Site creation failed. Please try again.")
return
}
// Check if the workflow reported failure
if !result.Success {
h.Logger.Warn("site creation workflow returned failure",
slog.String("domain", domain),
slog.String("error", result.ErrorMessage))
h.renderCreateFormError(w, session.WorkspaceID, result.ErrorMessage)
return
}
// Render success partial with HX-Trigger to refresh sites list
w.Header().Set("HX-Trigger", `{"refreshSites": true}`)
data := struct{ Domain string }{Domain: result.Domain}
h.Templates.Render(w, "fedwiki_create_success.html", data)
}
// DeleteConfirmData holds data for the delete confirmation partial.
type DeleteConfirmData struct {
Domain string
Error string
}
// GetDeleteConfirm handles GET /partials/fedwiki/delete-confirm/{domain}
func (h *FedWikiPartialsHandler) GetDeleteConfirm(w http.ResponseWriter, r *http.Request) {
domain := r.PathValue("domain")
if domain == "" {
http.Error(w, "Domain required", http.StatusBadRequest)
return
}
data := DeleteConfirmData{Domain: domain}
h.Templates.Render(w, "fedwiki_delete_confirm.html", data)
}
// DeleteSite handles DELETE /partials/fedwiki/sites/{domain}
func (h *FedWikiPartialsHandler) DeleteSite(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
h.Logger.Error("failed to get session")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
domain := r.PathValue("domain")
if domain == "" {
h.renderDeleteConfirmError(w, domain, "Domain is required")
return
}
// Verify site belongs to user's workspace
site, err := h.SiteQ.GetSiteByDomain(r.Context(), domain)
if err != nil {
h.Logger.Error("failed to get site", slog.Any("error", err))
h.renderDeleteConfirmError(w, domain, "Site not found")
return
}
if site.WorkspaceID != session.WorkspaceID {
h.renderDeleteConfirmError(w, domain, "You do not own this site")
return
}
// Check Temporal client
if h.TemporalClient == nil {
h.Logger.Error("Temporal client not configured")
h.renderDeleteConfirmError(w, domain, "Site provisioning service is not available")
return
}
// Start the workflow
workflowID := "delete-fedwiki-site-" + uuid.New().String()
workflowOptions := client.StartWorkflowOptions{
ID: workflowID,
TaskQueue: queues.Main,
}
input := fedwiki.DeleteFedWikiSiteWorkflowInput{
Domain: domain,
WorkspaceID: session.WorkspaceID,
SupportURL: h.SupportURL,
}
we, err := h.TemporalClient.ExecuteWorkflow(r.Context(), workflowOptions, fedwiki.DeleteFedWikiSiteWorkflow, input)
if err != nil {
h.Logger.Error("failed to start workflow", slog.Any("error", err))
h.renderDeleteConfirmError(w, domain, "Failed to initiate site deletion")
return
}
h.Logger.Info("site deletion workflow started",
slog.String("workflowID", we.GetID()),
slog.String("domain", domain))
// Wait for the workflow to complete
var result fedwiki.DeleteFedWikiSiteWorkflowOutput
if err := we.Get(r.Context(), &result); err != nil {
h.Logger.Error("workflow execution failed", slog.Any("error", err))
h.renderDeleteConfirmError(w, domain, "Site deletion failed. Please try again.")
return
}
// Check if the workflow reported failure
if !result.Success {
h.Logger.Warn("site deletion workflow returned failure",
slog.String("domain", domain),
slog.String("error", result.ErrorMessage))
h.renderDeleteConfirmError(w, domain, result.ErrorMessage)
return
}
// Render success partial with HX-Trigger to refresh sites list
w.Header().Set("HX-Trigger", `{"refreshSites": true}`)
data := struct{ Domain string }{Domain: domain}
h.Templates.Render(w, "fedwiki_delete_success.html", data)
}
// Helper methods
func (h *FedWikiPartialsHandler) buildSiteURL(domain string, isCustomDomain bool) string {
scheme := h.FedWikiSiteScheme
if scheme == "" {
scheme = "https"
}
return scheme + "://" + domain
}
func (h *FedWikiPartialsHandler) renderError(w http.ResponseWriter, tmplName string, message string) {
data := SitesData{Error: message}
h.Templates.Render(w, tmplName, data)
}
func (h *FedWikiPartialsHandler) renderCreateFormError(w http.ResponseWriter, workspaceID string, message string) {
var count int64
var quota int64
canCreate := false
q, err := h.getWorkspaceQuota(context.TODO(), workspaceID)
if err == nil {
quota = q.resourceLimit
canCreate = q.currentUsage < q.resourceLimit
count = q.currentUsage
}
data := CreateFormData{
AllowedDomains: h.FedWikiAllowedDomains,
CanCreate: canCreate,
CurrentCount: count,
Quota: quota,
Error: message,
}
h.Templates.Render(w, "fedwiki_create_form.html", data)
}
func (h *FedWikiPartialsHandler) renderDeleteConfirmError(w http.ResponseWriter, domain string, message string) {
data := DeleteConfirmData{Domain: domain, Error: message}
h.Templates.Render(w, "fedwiki_delete_confirm.html", data)
}
func (h *FedWikiPartialsHandler) getWorkspaceQuota(ctx context.Context, workspaceID string) (*workspaceQuota, error) {
assignment, err := h.EntitlementsQ.GetPrimaryPoolAssignmentByWorkspace(ctx, workspaceID)
if err != nil {
return nil, err
}
usage, err := h.EntitlementsQ.GetUsageByPoolAndResource(ctx, entitlements.GetUsageByPoolAndResourceParams{
PoolID: assignment.PoolID,
ResourceKey: "sites",
})
if err != nil {
return nil, err
}
ent, err := h.EntitlementsQ.GetNumericEntitlementByPoolAndResource(ctx, entitlements.GetNumericEntitlementByPoolAndResourceParams{
PoolID: assignment.PoolID,
ResourceKey: "sites",
})
if err != nil {
return nil, err
}
return &workspaceQuota{
currentUsage: usage.CurrentUsage,
resourceLimit: ent.ResourceLimit,
}, nil
}