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.
481 lines
14 KiB
Go
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
|
|
}
|