Files
member-console/internal/server/fedwiki_partials.go

473 lines
15 KiB
Go

package server
import (
"context"
"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/db"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
"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 {
DB db.Querier
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 *template.Template
}
// FedWikiPartialsConfig holds configuration for the FedWiki partials handler.
type FedWikiPartialsConfig struct {
DB db.Querier
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{
DB: cfg.DB,
Logger: cfg.Logger,
TemporalClient: cfg.TemporalClient,
AuthConfig: cfg.AuthConfig,
FedWikiAllowedDomains: cfg.FedWikiAllowedDomains,
FedWikiSiteScheme: cfg.FedWikiSiteScheme,
SupportURL: cfg.SupportURL,
Templates: tmpl,
}, 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)
}
// getUserIDFromSession extracts the user's database ID from the session.
func (h *FedWikiPartialsHandler) getUserIDFromSession(r *http.Request) (int64, error) {
session, err := h.AuthConfig.Store.Get(r, h.AuthConfig.SessionName)
if err != nil {
return 0, err
}
userID, ok := session.Values["user_db_id"].(int64)
if !ok {
return 0, err
}
return userID, nil
}
// SiteViewModel represents a site for template rendering.
type SiteViewModel struct {
ID int64
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
IsMember bool
CanCreate bool
Error string
}
// GetSites handles GET /partials/fedwiki/sites
func (h *FedWikiPartialsHandler) GetSites(w http.ResponseWriter, r *http.Request) {
userID, err := h.getUserIDFromSession(r)
if err != nil {
h.Logger.Error("failed to get user ID from session", slog.Any("error", err))
h.renderError(w, "fedwiki_sites.html", "Unauthorized")
return
}
// Get sites
sites, err := h.DB.GetSitesByUserID(r.Context(), userID)
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
}
// Get quota info
count, err := h.DB.GetSiteCountByUserID(r.Context(), userID)
if err != nil {
h.Logger.Error("failed to get site count", slog.Any("error", err))
h.renderError(w, "fedwiki_sites.html", "Failed to retrieve quota")
return
}
membership, err := h.DB.GetUserMembership(r.Context(), userID)
if err != nil {
h.Logger.Error("failed to get membership", slog.Any("error", err))
h.renderError(w, "fedwiki_sites.html", "Failed to retrieve membership")
return
}
// Build view models
siteVMs := make([]SiteViewModel, len(sites))
for i, site := range sites {
isCustom := site.IsCustomDomain != 0
siteVMs[i] = SiteViewModel{
ID: site.ID,
Domain: site.Domain,
IsCustomDomain: isCustom,
CreatedAt: site.CreatedAt.Format("Jan 2, 2006"),
URL: h.buildSiteURL(site.Domain, isCustom),
}
}
data := SitesData{
Sites: siteVMs,
CurrentCount: count,
Quota: membership.SitesQuota,
IsMember: membership.IsMember != 0,
CanCreate: count < membership.SitesQuota,
}
w.Header().Set("Content-Type", "text/html")
if err := h.Templates.ExecuteTemplate(w, "fedwiki_sites.html", data); err != nil {
h.Logger.Error("template error", slog.Any("error", err))
}
}
// 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
IsMember bool
Error string
}
// GetCreateForm handles GET /partials/fedwiki/create-form
func (h *FedWikiPartialsHandler) GetCreateForm(w http.ResponseWriter, r *http.Request) {
userID, err := h.getUserIDFromSession(r)
if err != nil {
h.Logger.Error("failed to get user ID from session", slog.Any("error", err))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Get quota info
count, err := h.DB.GetSiteCountByUserID(r.Context(), userID)
if err != nil {
h.Logger.Error("failed to get site count", slog.Any("error", err))
}
membership, err := h.DB.GetUserMembership(r.Context(), userID)
if err != nil {
h.Logger.Error("failed to get membership", slog.Any("error", err))
}
data := CreateFormData{
AllowedDomains: h.FedWikiAllowedDomains,
CanCreate: count < membership.SitesQuota,
CurrentCount: count,
Quota: membership.SitesQuota,
IsMember: membership.IsMember != 0,
}
w.Header().Set("Content-Type", "text/html")
if err := h.Templates.ExecuteTemplate(w, "fedwiki_create_form.html", data); err != nil {
h.Logger.Error("template error", slog.Any("error", err))
}
}
// CreateSite handles POST /partials/fedwiki/sites
func (h *FedWikiPartialsHandler) CreateSite(w http.ResponseWriter, r *http.Request) {
userID, err := h.getUserIDFromSession(r)
if err != nil {
h.Logger.Error("failed to get user ID from session", slog.Any("error", err))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Parse form
if err := r.ParseForm(); err != nil {
h.renderCreateFormError(w, userID, "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, userID, "Domain is required")
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, userID, "Invalid domain selected")
return
}
}
// Get user info
user, err := h.DB.GetUserByID(r.Context(), userID)
if err != nil {
h.Logger.Error("failed to get user", slog.Any("error", err))
h.renderCreateFormError(w, userID, "Failed to get user information")
return
}
// Check Temporal client
if h.TemporalClient == nil {
h.Logger.Error("Temporal client not configured")
h.renderCreateFormError(w, userID, "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{
UserID: userID,
Domain: domain,
SiteDomain: selectedDomain, // The domain suffix (e.g., "localtest.me")
OwnerName: user.Username,
OwnerID: user.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, userID, "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, userID, "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, userID, result.ErrorMessage)
return
}
// Render success partial with HX-Trigger to refresh sites list
w.Header().Set("Content-Type", "text/html")
w.Header().Set("HX-Trigger", `{"refreshSites": true}`)
data := struct{ Domain string }{Domain: result.Domain}
if err := h.Templates.ExecuteTemplate(w, "fedwiki_create_success.html", data); err != nil {
h.Logger.Error("template error", slog.Any("error", err))
}
}
// 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}
w.Header().Set("Content-Type", "text/html")
if err := h.Templates.ExecuteTemplate(w, "fedwiki_delete_confirm.html", data); err != nil {
h.Logger.Error("template error", slog.Any("error", err))
}
}
// DeleteSite handles DELETE /partials/fedwiki/sites/{domain}
func (h *FedWikiPartialsHandler) DeleteSite(w http.ResponseWriter, r *http.Request) {
userID, err := h.getUserIDFromSession(r)
if err != nil {
h.Logger.Error("failed to get user ID from session", slog.Any("error", err))
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
site, err := h.DB.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.UserID != userID {
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{
UserID: userID,
Domain: domain,
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("Content-Type", "text/html")
w.Header().Set("HX-Trigger", `{"refreshSites": true}`)
data := struct{ Domain string }{Domain: domain}
if err := h.Templates.ExecuteTemplate(w, "fedwiki_delete_success.html", data); err != nil {
h.Logger.Error("template error", slog.Any("error", err))
}
}
// Helper methods
func (h *FedWikiPartialsHandler) buildSiteURL(domain string, isCustomDomain bool) string {
// Domain is stored as full domain (e.g., test.localtest.me or custom.example.com)
// Just need to add the appropriate scheme
scheme := h.FedWikiSiteScheme
if scheme == "" {
scheme = "https"
}
return scheme + "://" + domain
}
func (h *FedWikiPartialsHandler) renderError(w http.ResponseWriter, template string, message string) {
w.Header().Set("Content-Type", "text/html")
data := SitesData{Error: message}
h.Templates.ExecuteTemplate(w, template, data)
}
func (h *FedWikiPartialsHandler) renderCreateFormError(w http.ResponseWriter, userID int64, message string) {
count, _ := h.DB.GetSiteCountByUserID(context.TODO(), userID)
membership, _ := h.DB.GetUserMembership(context.TODO(), userID)
data := CreateFormData{
AllowedDomains: h.FedWikiAllowedDomains,
CanCreate: count < membership.SitesQuota,
CurrentCount: count,
Quota: membership.SitesQuota,
IsMember: membership.IsMember != 0,
Error: message,
}
w.Header().Set("Content-Type", "text/html")
h.Templates.ExecuteTemplate(w, "fedwiki_create_form.html", data)
}
func (h *FedWikiPartialsHandler) renderDeleteConfirmError(w http.ResponseWriter, domain string, message string) {
data := DeleteConfirmData{Domain: domain, Error: message}
w.Header().Set("Content-Type", "text/html")
h.Templates.ExecuteTemplate(w, "fedwiki_delete_confirm.html", data)
}