Derive Stripe test/live mode from the API key prefix at boot, failing on unrecognized prefixes, and drop the separate `stripe-mode` config key. Refine disabled controls to render through the shared `disabledControl` part with the not-allowed cursor, and add a lint rule refusing hand-rolled disabled buttons. Adjust plan cards to offer no purchase control on free rungs, fix bound checkbox Bool handling, and rename "Public/Private" to "Listed/Unlisted" with enhanced readiness verdicts.
1344 lines
54 KiB
Go
1344 lines
54 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package web
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/dnsname"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
|
|
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
|
|
siteusage "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/usage"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/workflows"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
|
|
coreweb "git.coopcloud.tech/wiki-cafe/member-console/internal/web"
|
|
"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 {
|
|
Include server.Include
|
|
SiteQ fwmod.Querier
|
|
EntitlementsQ entitlements.Querier
|
|
// Registry is the domains registry: the authority for every name FedWiki
|
|
// serves. The external-domain flow reads and writes claims through it
|
|
// (design D4/D8). Nil in render tests, which touch no claim path.
|
|
Registry *domains.Registry
|
|
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
|
|
CustomDomainTarget string // DNS target members point custom domains at; empty disables the flow
|
|
// CustomDomainsGate is the shared external-claim gate
|
|
// (centralize-external-claim-gate): the same func the registry enforces
|
|
// at ClaimExternal, used here only to decide affordance rendering.
|
|
CustomDomainsGate domains.ExternalClaimGate
|
|
SupportURL string
|
|
SwapCooldown time.Duration // active-site rotation cooldown window
|
|
Templates *server.SafeTemplates
|
|
}
|
|
|
|
// FedWikiPartialsConfig holds configuration for the FedWiki partials handler.
|
|
type FedWikiPartialsConfig struct {
|
|
SiteQ fwmod.Querier
|
|
EntitlementsQ entitlements.Querier
|
|
Registry *domains.Registry
|
|
Database *sql.DB
|
|
Logger *slog.Logger
|
|
TemporalClient client.Client
|
|
AuthConfig *auth.Config
|
|
FedWikiAllowedDomains []string
|
|
FedWikiSiteScheme string
|
|
CustomDomainTarget string
|
|
CustomDomainsGate domains.ExternalClaimGate
|
|
SupportURL string
|
|
SwapCooldown time.Duration
|
|
// TemplatesFS is FedWiki's own template directory (fedwiki_*.html),
|
|
// supplied by the Adapter from its embedded templates tree — see
|
|
// internal/integrations/fedwiki.Adapter.Templates (the UIProvider hook)
|
|
// and design.md Decision 8. This package cannot embed the tree itself:
|
|
// the templates live at the integration root (a sibling of this
|
|
// package, per design.md Decision 1's tree layout), and importing the
|
|
// root package from here to reach an embed.FS would cycle back through
|
|
// RegisterRoutes, which constructs these handlers.
|
|
TemplatesFS fs.FS
|
|
// Include composes core's domains surface into the sites card on the
|
|
// server (page-anatomy "A page arrives complete"); supplied by core
|
|
// through server.Deps.
|
|
Include server.Include
|
|
}
|
|
|
|
// NewFedWikiPartialsHandler creates a new FedWikiPartialsHandler.
|
|
func NewFedWikiPartialsHandler(cfg FedWikiPartialsConfig) (*FedWikiPartialsHandler, error) {
|
|
// Parse only FedWiki's own member-facing partials by explicit name — not
|
|
// a "*.html" glob — so this template set never depends on function names
|
|
// (renderBody, fieldErr, stripeEntityURL) that other integrations' or
|
|
// core's operator-side templates need. Only routeURL is required: two of
|
|
// these four templates link back into their own HTMX routes with it.
|
|
// fedwiki_delete_confirm.html and fedwiki_delete_success.html are gone
|
|
// (finding FA-41): permanent delete runs through the shared confirmAction
|
|
// modal like every other site action, so it never needed a template of
|
|
// its own for either the confirm step or the result.
|
|
tmpl, err := template.New("fedwiki-partials").Funcs(template.FuncMap{
|
|
"routeURL": coreweb.RouteURL,
|
|
}).ParseFS(cfg.TemplatesFS,
|
|
"fedwiki_sites.html",
|
|
"fedwiki_create_form.html",
|
|
"fedwiki_create_success.html",
|
|
"fedwiki_custom_domain_pending.html",
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// The shared page-anatomy parts (statusBadge, emptyState) render the
|
|
// badges and empty states these member partials use (sweep group 10).
|
|
if tmpl, err = coreweb.ParseUIPartials(tmpl); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &FedWikiPartialsHandler{
|
|
Include: cfg.Include,
|
|
SiteQ: cfg.SiteQ,
|
|
EntitlementsQ: cfg.EntitlementsQ,
|
|
Registry: cfg.Registry,
|
|
Database: cfg.Database,
|
|
Logger: cfg.Logger,
|
|
TemporalClient: cfg.TemporalClient,
|
|
AuthConfig: cfg.AuthConfig,
|
|
FedWikiAllowedDomains: cfg.FedWikiAllowedDomains,
|
|
FedWikiSiteScheme: cfg.FedWikiSiteScheme,
|
|
CustomDomainTarget: cfg.CustomDomainTarget,
|
|
CustomDomainsGate: cfg.CustomDomainsGate,
|
|
SupportURL: cfg.SupportURL,
|
|
SwapCooldown: cfg.SwapCooldown,
|
|
Templates: server.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("GET /partials/fedwiki/custom-domains/{claimID}", h.GetCustomDomainStatus)
|
|
mux.HandleFunc("POST /partials/fedwiki/custom-domains/{claimID}/check", h.CheckCustomDomainNow)
|
|
mux.HandleFunc("POST /partials/fedwiki/custom-domains/{claimID}/cancel", h.CancelCustomDomainVerification)
|
|
mux.HandleFunc("POST /partials/fedwiki/sites", h.CreateSite)
|
|
mux.HandleFunc("POST /partials/fedwiki/sites/{domain}/archive", h.ArchiveSite)
|
|
mux.HandleFunc("POST /partials/fedwiki/sites/{domain}/restore", h.RestoreSite)
|
|
mux.HandleFunc("POST /partials/fedwiki/sites/{domain}/keep-active", h.KeepSiteActive)
|
|
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
|
|
Status string // active | readonly | archived
|
|
IsReadonly bool
|
|
IsArchived bool
|
|
IsForceReduced bool // parked by a downgrade/swap rotation, not archived by choice — Keep active/Restore on this site is cooldown-gated
|
|
}
|
|
|
|
// SitesData holds data for the sites list partial.
|
|
type SitesData struct {
|
|
Sites []SiteViewModel // active + read-only (the live list)
|
|
ArchivedSites []SiteViewModel // archived (shown in a separate section)
|
|
HasReadonly bool // any site parked read-only by a downgrade
|
|
CurrentCount int64
|
|
Quota int64
|
|
CanCreate bool
|
|
HasEntitlement bool
|
|
// EntitlementCheckFailed marks a transient quota-lookup failure (audit
|
|
// #24 interim guard): the member's sites must stay listed with an
|
|
// explicit try-again notice, never masquerade as "no access granted".
|
|
EntitlementCheckFailed bool
|
|
// NoDomainsConfigured mirrors the create-form's zero-domains guard
|
|
// (task 4.6): true when the deployment has no FedWikiAllowedDomains at
|
|
// all, so no member can create a hosted site regardless of their plan.
|
|
// Unlike HasEntitlement this is never the member's fault, and the blocked
|
|
// empty state below names it separately (ux-first-run task 3.6) so a
|
|
// member is never told to check their plan when the real blocker is a
|
|
// deployment that hasn't configured any site domains yet.
|
|
NoDomainsConfigured bool
|
|
// NoPublishedPlan is the entitlement branch's third case (fedwiki-sites
|
|
// spec "The sites card distinguishes an empty catalog from an excluded
|
|
// entitlement", ACC-7): true when no published product's entitlement
|
|
// set confers the fedwiki_sites resource key at all, so the empty
|
|
// state says the deployment hasn't published a website plan yet
|
|
// instead of blaming the member's own plan. Only meaningful (and only
|
|
// computed) when HasEntitlement is false.
|
|
NoPublishedPlan bool
|
|
// CooldownMsg, when non-empty, is the remaining-time message for the
|
|
// workspace's active-site swap cooldown. Read-only display: the buttons
|
|
// gated by it (readonly sites' Keep active, force-reduced archived sites'
|
|
// Restore) are always the same rotation the server enforces on click —
|
|
// see checkSwapCooldown and RestoreSite/KeepSiteActive's `rotation` gates.
|
|
CooldownMsg string
|
|
// DomainsSurface is core's member domains surface, composed on the
|
|
// server (page-anatomy "A page arrives complete").
|
|
DomainsSurface template.HTML
|
|
// CustomDomainClaims lists this workspace's VERIFIED external domain
|
|
// claims that have no site yet — verification no longer creates the site
|
|
// itself (design D4), so the verified state needs its one-click create
|
|
// somewhere. Pending claims are deliberately absent
|
|
// (dissolve-member-domains): they already surface via the dashboard's
|
|
// pending notice and the embedded Domains section below the sites list.
|
|
CustomDomainClaims []CustomDomainClaimViewModel
|
|
Error string
|
|
}
|
|
|
|
// CustomDomainClaimViewModel is one verified external domain claim without a
|
|
// site — the banner's single remaining job is its one-click create.
|
|
type CustomDomainClaimViewModel struct {
|
|
ClaimID string
|
|
Domain string
|
|
CreatedAt string
|
|
}
|
|
|
|
// CreateControl builds the shared disabledControl part's data (design D6,
|
|
// spec form-conventions "Disabled controls carry their reason in the DOM")
|
|
// for the Create site button when CanCreate is false: the member surface's
|
|
// visible-reason variant (AsVisible), the same idiom member_products.go
|
|
// uses. The reason names the limit when the workspace holds an entitlement
|
|
// at or over it; otherwise the allowance itself could not be read.
|
|
func (d SitesData) CreateControl() server.DisabledControl {
|
|
reason := "Site creation is unavailable right now"
|
|
if d.HasEntitlement {
|
|
reason = "Site limit reached"
|
|
}
|
|
return server.NewDisabledControl(
|
|
"fedwiki-create-site-reason",
|
|
"Create site",
|
|
"btn btn-outline-primary btn-sm",
|
|
reason,
|
|
).AsVisible()
|
|
}
|
|
|
|
// KeepActiveControl builds the shared disabledControl part's data for a
|
|
// read-only site's Keep active button during the swap cooldown (design D6):
|
|
// the tooltip-on-wrapper variant, since the row it sits in has no room to
|
|
// stack a visible reason beside each action. The id is unique per site so
|
|
// several rows on the same page never collide.
|
|
func (s SiteViewModel) KeepActiveControl(reason string) server.DisabledControl {
|
|
return server.NewDisabledControl(
|
|
"fedwiki-site-keep-active-reason-"+s.ID,
|
|
"Keep active",
|
|
"btn btn-outline-primary btn-sm me-1",
|
|
reason,
|
|
)
|
|
}
|
|
|
|
// RestoreControl builds the shared disabledControl part's data for a
|
|
// force-reduced archived site's Restore button during the swap cooldown
|
|
// (design D6), the same tooltip variant KeepActiveControl uses and for the
|
|
// same reason.
|
|
func (s SiteViewModel) RestoreControl(reason string) server.DisabledControl {
|
|
return server.NewDisabledControl(
|
|
"fedwiki-site-restore-reason-"+s.ID,
|
|
"Restore",
|
|
"btn btn-outline-primary btn-sm me-1",
|
|
reason,
|
|
)
|
|
}
|
|
|
|
// 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, splitting the live list (active + read-only) from the
|
|
// Archived section. Archived sites are hidden from the active list.
|
|
var siteVMs, archivedVMs []SiteViewModel
|
|
hasReadonly := false
|
|
for _, site := range sites {
|
|
vm := SiteViewModel{
|
|
ID: site.SiteID,
|
|
Domain: site.Domain,
|
|
IsCustomDomain: site.IsCustomDomain,
|
|
CreatedAt: site.CreatedAt.Format("Jan 2, 2006"),
|
|
URL: h.buildSiteURL(site.Domain, site.IsCustomDomain),
|
|
Status: site.Status,
|
|
IsReadonly: site.Status == "readonly",
|
|
IsArchived: site.Status == "archived",
|
|
IsForceReduced: site.ForceReducedAt.Valid,
|
|
}
|
|
switch site.Status {
|
|
case "archived":
|
|
archivedVMs = append(archivedVMs, vm)
|
|
default:
|
|
if vm.IsReadonly {
|
|
hasReadonly = true
|
|
}
|
|
siteVMs = append(siteVMs, vm)
|
|
}
|
|
}
|
|
|
|
// Get quota from entitlements
|
|
count := int64(len(siteVMs))
|
|
var quota int64
|
|
canCreate := false
|
|
hasEntitlement := false
|
|
|
|
q, err := h.getWorkspaceQuota(r.Context(), workspaceID)
|
|
entitlementCheckFailed := false
|
|
if err == nil {
|
|
hasEntitlement = true
|
|
quota = q.resourceLimit
|
|
canCreate = q.currentUsage < q.resourceLimit
|
|
count = q.currentUsage
|
|
} else if !errors.Is(err, sql.ErrNoRows) {
|
|
// Only a no-rows result means "no entitlement"; anything else is a
|
|
// transient lookup failure and must not hide the member's live sites
|
|
// behind the no-access empty state (audit #24 interim guard).
|
|
h.Logger.Error("failed to load workspace quota", slog.Any("error", err))
|
|
entitlementCheckFailed = true
|
|
}
|
|
|
|
// Read-only cooldown check for the Keep active/Restore hints below — the
|
|
// authoritative gate is still re-checked server-side on click.
|
|
cooldownMsg := h.checkSwapCooldown(r.Context(), workspaceID)
|
|
|
|
// The "no plan includes this yet" branch only matters when the member
|
|
// has no entitlement to begin with; skip the catalog query otherwise.
|
|
noPublishedPlan := false
|
|
if !hasEntitlement {
|
|
noPublishedPlan = !h.anyPublishedProductConfersResourceKey(r.Context(), "fedwiki_sites")
|
|
}
|
|
|
|
data := SitesData{
|
|
Sites: siteVMs,
|
|
ArchivedSites: archivedVMs,
|
|
HasReadonly: hasReadonly,
|
|
CurrentCount: count,
|
|
Quota: quota,
|
|
CanCreate: canCreate,
|
|
HasEntitlement: hasEntitlement,
|
|
EntitlementCheckFailed: entitlementCheckFailed,
|
|
NoDomainsConfigured: len(h.FedWikiAllowedDomains) == 0,
|
|
NoPublishedPlan: noPublishedPlan,
|
|
CooldownMsg: cooldownMsg,
|
|
CustomDomainClaims: h.customDomainClaims(r.Context(), workspaceID),
|
|
DomainsSurface: h.domainsSurface(r),
|
|
}
|
|
|
|
h.Templates.Render(w, "fedwiki_sites.html", data)
|
|
}
|
|
|
|
// anyPublishedProductConfersResourceKey reports whether the catalog has at
|
|
// least one published product whose entitlement set confers resourceKey —
|
|
// the site card's third empty-state branch needs to tell "this deployment
|
|
// hasn't published a plan for this yet" apart from "your plan excludes
|
|
// this" (fedwiki-sites spec "The sites card distinguishes an empty catalog
|
|
// from an excluded entitlement", ACC-7). A query failure fails toward the
|
|
// entitlement-branch copy (true): a transient DB hiccup must never render
|
|
// the "no plans exist" claim when plans might well exist.
|
|
func (h *FedWikiPartialsHandler) anyPublishedProductConfersResourceKey(ctx context.Context, resourceKey string) bool {
|
|
var exists bool
|
|
err := h.Database.QueryRowContext(ctx, `
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM core.products p
|
|
JOIN core.entitlement_set_rules r ON r.set_id = p.entitlement_set_id
|
|
WHERE p.lifecycle_status = 'published'
|
|
AND r.is_active = TRUE
|
|
AND r.resource_key = $1
|
|
)`, resourceKey).Scan(&exists)
|
|
if err != nil {
|
|
h.Logger.Warn("failed to check published plan for resource key", slog.String("resource_key", resourceKey), slog.Any("error", err))
|
|
return true
|
|
}
|
|
return exists
|
|
}
|
|
|
|
// domainsSurface composes core's member domains surface into the card on
|
|
// the server; a failure renders an inline notice, never a blank region.
|
|
func (h *FedWikiPartialsHandler) domainsSurface(r *http.Request) template.HTML {
|
|
if h.Include == nil {
|
|
return ""
|
|
}
|
|
out, err := h.Include(r, "/partials/domains/claims")
|
|
if err != nil {
|
|
h.Logger.Error("domains surface failed to compose", slog.Any("error", err))
|
|
return template.HTML(`<div class="alert alert-danger" role="alert">Could not load domains.</div>`)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// customDomainClaims builds the sites-list banner rows: the workspace's
|
|
// VERIFIED external claims with no placement yet — ready for the one-click
|
|
// create. (Pending claims left the banner in dissolve-member-domains; the
|
|
// dashboard notice and the embedded Domains section carry them now.)
|
|
//
|
|
// Carved `member` claims are excluded: they exist only to hold a hosted
|
|
// site's name and auto-release when emptied, so a zero-placement one is a
|
|
// transient saga state, not something to show a member. Best-effort — the
|
|
// sites list must render even when the registry read fails.
|
|
func (h *FedWikiPartialsHandler) customDomainClaims(ctx context.Context, workspaceID string) []CustomDomainClaimViewModel {
|
|
if h.Registry == nil {
|
|
return nil
|
|
}
|
|
claims, err := h.Registry.ListLiveClaims(ctx, workspaceID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to list domain claims", slog.Any("error", err))
|
|
return nil
|
|
}
|
|
var out []CustomDomainClaimViewModel
|
|
for _, claim := range claims {
|
|
if claim.Kind != domains.KindExternal {
|
|
continue
|
|
}
|
|
// Verified-and-empty only (dissolve-member-domains): pending claims
|
|
// surface via the dashboard notice and the embedded Domains section,
|
|
// so the banner keeps the one job nothing else can host — the
|
|
// one-click create that verification no longer performs itself (D4).
|
|
if claim.Status != domains.StatusActive || claim.PlacementCount > 0 {
|
|
continue
|
|
}
|
|
out = append(out, CustomDomainClaimViewModel{
|
|
ClaimID: claim.ClaimID,
|
|
Domain: claim.RootFqdn,
|
|
CreatedAt: claim.CreatedAt.Format("Jan 2, 2006"),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// hasLiveExternalClaim reports whether the workspace already holds a live
|
|
// external claim — pending or verified, placed or not. It exists only to keep
|
|
// the create form's external-domain affordance visible for a workspace whose
|
|
// entitlement is gone but whose claim is not: creation under an owned claim
|
|
// is instant and ungated, so the input has real work left to do. Best-effort;
|
|
// a registry read failure falls back to the entitlement's answer.
|
|
func (h *FedWikiPartialsHandler) hasLiveExternalClaim(ctx context.Context, workspaceID string) bool {
|
|
if h.Registry == nil {
|
|
return false
|
|
}
|
|
claims, err := h.Registry.ListLiveClaims(ctx, workspaceID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to list domain claims", slog.Any("error", err))
|
|
return false
|
|
}
|
|
for _, claim := range claims {
|
|
if claim.Kind == domains.KindExternal {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// CreateFormData holds data for the create form partial.
|
|
type CreateFormData struct {
|
|
AllowedDomains []string // All domains where users can create sites, guards the zero-domains branch
|
|
Form forms.FormView
|
|
}
|
|
|
|
// resolveCreateFormAffordances computes the create form's runtime state,
|
|
// which both an unbound render (GetCreateForm) and a refused resubmission
|
|
// (renderCreateFormRefusal) need alike: whether the "use a domain I own"
|
|
// affordance is open, and the commit's disabled state when the workspace
|
|
// is at its site quota (finding FA-17, through the shared disabledControl
|
|
// part rather than a bare `disabled` and an unrelated alert).
|
|
func (h *FedWikiPartialsHandler) resolveCreateFormAffordances(ctx context.Context, workspaceID string) (customDomainsOpen bool, commitState *forms.DisabledControlView) {
|
|
var count, quota int64
|
|
canCreate := false
|
|
q, err := h.getWorkspaceQuota(ctx, workspaceID)
|
|
if err == nil {
|
|
quota = q.resourceLimit
|
|
count = q.currentUsage
|
|
canCreate = count < quota
|
|
}
|
|
if !canCreate {
|
|
commitState = &forms.DisabledControlView{
|
|
ID: "fedwiki-site-create-commit-reason",
|
|
Label: "Create site",
|
|
Classes: "btn btn-primary",
|
|
Reason: fmt.Sprintf("You have reached your site limit (%d of %d sites).", count, quota),
|
|
// Member surface density (anatomy_disabled.go's AsVisible):
|
|
// the reason renders as ordinary text, not a tooltip.
|
|
Visible: true,
|
|
}
|
|
}
|
|
|
|
cd, cdErr := customDomainsEnabled(ctx, h.CustomDomainsGate, workspaceID)
|
|
if cdErr != nil {
|
|
h.Logger.Error("custom-domain entitlement check failed", slog.Any("error", cdErr))
|
|
}
|
|
// A workspace that already holds a live external claim keeps the
|
|
// affordance whatever the entitlement says: the gate covers NEW claims
|
|
// only, and names under a claim it already owns still place instantly.
|
|
// Hiding the input would strand the claim with no way to use it. The
|
|
// server-side gate in CreateSite stays authoritative either way.
|
|
if !cd {
|
|
cd = h.hasLiveExternalClaim(ctx, workspaceID)
|
|
}
|
|
return cd, commitState
|
|
}
|
|
|
|
// 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
|
|
customDomains, commitState := h.resolveCreateFormAffordances(r.Context(), workspaceID)
|
|
|
|
// customDomainsEnabled is the hidden field isCustomDomain's ShowIf
|
|
// reads (design D2); it carries no authority of its own (finding
|
|
// FA-10) and is set fresh on every render, never read back from a
|
|
// submission.
|
|
values := forms.NewValues()
|
|
enabledRaw := ""
|
|
if customDomains {
|
|
enabledRaw = "true"
|
|
}
|
|
values.Set("customDomainsEnabled", enabledRaw)
|
|
|
|
view := forms.Render(siteCreateForm, forms.Binding{
|
|
Mode: forms.ModeUnbound,
|
|
Values: values,
|
|
Options: siteCreateOptions(h.FedWikiAllowedDomains),
|
|
})
|
|
view.CommitState = commitState
|
|
|
|
h.Templates.Render(w, "fedwiki_create_form.html", CreateFormData{
|
|
AllowedDomains: h.FedWikiAllowedDomains,
|
|
Form: view,
|
|
})
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
values, errs := siteCreateForm.ParseWith(r, siteCreateOptions(h.FedWikiAllowedDomains))
|
|
if errs.Any() {
|
|
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
|
|
return
|
|
}
|
|
|
|
// Normalize before anything reads the name: the registry stores and
|
|
// compares normalized names, so an absolute FQDN ("wiki.example.org.")
|
|
// must lose its root label here or the row and the farm call disagree.
|
|
domain := dnsname.Normalize(values.String("domain"))
|
|
// selectedDomain is "" for a custom-domain submission: the field is not
|
|
// shown while isCustomDomain is checked (forms.go's field-order
|
|
// comment), and the workflow input documents SiteDomain as ignored for
|
|
// custom domains, so an empty value there is correct, not missing data.
|
|
selectedDomain := values.String("selectedDomain")
|
|
isCustomDomain := values.Bool("isCustomDomain")
|
|
|
|
// External-domain branch: the registry's four-branch disposition (design
|
|
// D4). Only the unclaimed branch starts a verification; a name already
|
|
// inside the workspace's active claim falls through to the ordinary
|
|
// create path below and is created instantly.
|
|
//
|
|
// Classification comes FIRST, and the entitlement gates the unclaimed
|
|
// branch alone: the boolean buys the right to claim a NEW external
|
|
// domain, not the right to use one already claimed. A name under a claim
|
|
// the workspace already holds places instantly whatever the plan says
|
|
// ("Nested site under an owned claim needs no verification"), so a
|
|
// revoked entitlement must not strand a live claim.
|
|
if isCustomDomain {
|
|
disposition, userMsg, err := classifyCustomDomain(r.Context(), h.Registry, session.WorkspaceID, domain)
|
|
if err != nil {
|
|
h.Logger.Error("domain availability check failed", slog.Any("error", err))
|
|
errs.Form("We couldn't check that domain right now. Try again.")
|
|
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
|
|
return
|
|
}
|
|
switch disposition {
|
|
case dispositionRefused, dispositionOwnPending:
|
|
// userMsg is about the typed domain itself, so it lands under
|
|
// that field rather than in the form-level slot (finding
|
|
// FA-27, carrying FA-8's field-attribution rule to this form).
|
|
errs.Field("domain", userMsg)
|
|
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
|
|
return
|
|
case dispositionUnclaimed:
|
|
enabled, err := customDomainsEnabled(r.Context(), h.CustomDomainsGate, session.WorkspaceID)
|
|
if err != nil {
|
|
h.Logger.Error("custom-domain entitlement check failed", slog.Any("error", err))
|
|
errs.Form("We couldn't check your plan right now. Try again.")
|
|
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
|
|
return
|
|
}
|
|
if !enabled {
|
|
errs.Form("Your current plan doesn't include custom domains.")
|
|
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
|
|
return
|
|
}
|
|
initiation, msg := startClaimVerification(r.Context(), h.Registry, h.TemporalClient, h.Logger,
|
|
session.WorkspaceID, domain, h.CustomDomainTarget)
|
|
if msg != "" {
|
|
errs.Form(msg)
|
|
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
|
|
return
|
|
}
|
|
h.Templates.Render(w, "fedwiki_custom_domain_pending.html", CustomDomainPendingData{
|
|
ClaimID: initiation.ClaimID,
|
|
Domain: initiation.Domain,
|
|
Records: newCustomDomainRecords(initiation.Domain,
|
|
initiation.TXTRecordName, initiation.TXTRecordValue, initiation.ConnectTarget),
|
|
StatusText: "Waiting for your DNS records…",
|
|
// With the time of day, matching the status view: the
|
|
// verification window is hours, not days.
|
|
ExpiresAt: initiation.ExpiresAt.Format("Jan 2, 2006 15:04 MST"),
|
|
Polling: true,
|
|
})
|
|
// The banner gains the pending claim.
|
|
w.Header().Set("HX-Trigger", `{"refreshSites": true}`)
|
|
return
|
|
}
|
|
// dispositionOwnActive falls through: the claim already proves control.
|
|
} else {
|
|
// Hosted path only: a custom domain is a full FQDN, not a label
|
|
// under one of the farm domains. selectedDomain is already one of
|
|
// h.FedWikiAllowedDomains here: Parse refused anything else against
|
|
// siteCreateOptions above, so the old manual membership check is
|
|
// gone.
|
|
|
|
// Validate domain is a legal DNS label (lowercase enforced by
|
|
// dnsname.Normalize above).
|
|
if errMsg := dnsname.ValidateDNSLabel(domain); errMsg != "" {
|
|
errs.Field("domain", errMsg)
|
|
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
|
|
return
|
|
}
|
|
|
|
// Defense-in-depth: check assembled FQDN length (RFC 1035 §2.3.4)
|
|
if errMsg := dnsname.ValidateFQDN(domain, selectedDomain); errMsg != "" {
|
|
errs.Field("domain", errMsg)
|
|
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Check Temporal client
|
|
if h.TemporalClient == nil {
|
|
h.Logger.Error("Temporal client not configured")
|
|
errs.Form("Site creation service is not available")
|
|
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
|
|
return
|
|
}
|
|
|
|
// Start the workflow
|
|
workflowID := "create-fedwiki-site-" + uuid.New().String()
|
|
workflowOptions := client.StartWorkflowOptions{
|
|
ID: workflowID,
|
|
TaskQueue: queues.Main,
|
|
}
|
|
|
|
input := workflows.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, workflows.CreateFedWikiSiteWorkflow, input)
|
|
if err != nil {
|
|
h.Logger.Error("failed to start workflow", slog.Any("error", err))
|
|
errs.Form("Failed to initiate site creation")
|
|
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
|
|
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 — but only within the wait budget, so
|
|
// the still-working answer can land before the server's write deadline.
|
|
waitCtx, cancelWait := context.WithTimeout(r.Context(), workflowWaitBudget)
|
|
defer cancelWait()
|
|
var result workflows.CreateFedWikiSiteWorkflowOutput
|
|
if err := we.Get(waitCtx, &result); err != nil {
|
|
if workflowWaitInterrupted(err) {
|
|
h.Logger.Warn("site creation outlived the request wait; workflow still running",
|
|
slog.String("workflowID", we.GetID()), slog.Any("error", err))
|
|
h.siteActionPending(w)
|
|
return
|
|
}
|
|
h.Logger.Error("workflow execution failed", slog.Any("error", err))
|
|
errs.Form("Site creation failed. Try again.")
|
|
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
|
|
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))
|
|
errs.Form(result.ErrorMessage)
|
|
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
|
|
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)
|
|
}
|
|
|
|
// DeleteSite handles DELETE /partials/fedwiki/sites/{domain}: permanent
|
|
// delete, triggered through the shared confirmAction modal like every
|
|
// other site-status action in this file (finding FA-41: the delete-confirm
|
|
// modal this once opened, and its GET route, were dead weight, superseded
|
|
// by that shared trigger and never actually reachable through it; its
|
|
// hx-target named a node, #deleteSiteModalBody, no live button pointed
|
|
// at). Errors and success both answer through siteActionResult, exactly as
|
|
// ArchiveSite, RestoreSite and KeepSiteActive already do, into the
|
|
// "Delete permanently" trigger's own data-action-target: the modal-shaped
|
|
// success and error partials this replaced were swapping into that same
|
|
// small inline target and had never fit it.
|
|
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.siteActionResult(w, "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.siteActionResult(w, "Site not found")
|
|
return
|
|
}
|
|
|
|
if site.WorkspaceID != session.WorkspaceID {
|
|
// A distinct answer here is an existence oracle for other tenants'
|
|
// domains (security-audit-remediation-2 design D5): answer exactly
|
|
// as the failed-lookup branch above, and keep the distinction in
|
|
// the log only.
|
|
h.Logger.Debug("site delete refused: not the caller's workspace",
|
|
slog.String("domain", domain),
|
|
slog.String("caller_workspace", session.WorkspaceID),
|
|
slog.String("owner_workspace", site.WorkspaceID))
|
|
h.siteActionResult(w, "Site not found")
|
|
return
|
|
}
|
|
|
|
// Check Temporal client
|
|
if h.TemporalClient == nil {
|
|
h.Logger.Error("Temporal client not configured")
|
|
h.siteActionResult(w, "Site creation service is not available")
|
|
return
|
|
}
|
|
|
|
// Start the workflow
|
|
workflowID := "delete-fedwiki-site-" + uuid.New().String()
|
|
workflowOptions := client.StartWorkflowOptions{
|
|
ID: workflowID,
|
|
TaskQueue: queues.Main,
|
|
}
|
|
|
|
input := workflows.DeleteFedWikiSiteWorkflowInput{
|
|
Domain: domain,
|
|
WorkspaceID: session.WorkspaceID,
|
|
WasActive: site.Status == "active",
|
|
SupportURL: h.SupportURL,
|
|
}
|
|
|
|
we, err := h.TemporalClient.ExecuteWorkflow(r.Context(), workflowOptions, workflows.DeleteFedWikiSiteWorkflow, input)
|
|
if err != nil {
|
|
h.Logger.Error("failed to start workflow", slog.Any("error", err))
|
|
h.siteActionResult(w, "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 — bounded like CreateSite's wait.
|
|
waitCtx, cancelWait := context.WithTimeout(r.Context(), workflowWaitBudget)
|
|
defer cancelWait()
|
|
var result workflows.DeleteFedWikiSiteWorkflowOutput
|
|
if err := we.Get(waitCtx, &result); err != nil {
|
|
if workflowWaitInterrupted(err) {
|
|
h.Logger.Warn("site deletion outlived the request wait; workflow still running",
|
|
slog.String("workflowID", we.GetID()), slog.Any("error", err))
|
|
h.siteActionPending(w)
|
|
return
|
|
}
|
|
h.Logger.Error("workflow execution failed", slog.Any("error", err))
|
|
h.siteActionResult(w, "Site deletion failed. 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.siteActionResult(w, result.ErrorMessage)
|
|
return
|
|
}
|
|
|
|
// Success: HX-Trigger refreshes the sites list, which is the
|
|
// confirmation; the deleted row is simply gone from it.
|
|
h.siteActionResult(w, "")
|
|
}
|
|
|
|
// changeSiteStatus verifies ownership and runs SetSiteStatusWorkflow to move the
|
|
// caller's site to target (active|readonly|archived). Returns a user-facing error
|
|
// message ("" on success) plus pending=true when the request stopped waiting but
|
|
// the workflow is still running (see workflowWaitInterrupted).
|
|
//
|
|
// Every member status transition in this file — ArchiveSite, RestoreSite, and
|
|
// KeepSiteActive's below-the-limit branch — funnels through here, and the
|
|
// swap branch funnels through SwapActiveSiteWorkflow. That is why no handler
|
|
// in this package touches the domains registry directly: the placement's
|
|
// `servable` flag is written where the status write actually lands, in
|
|
// workflows.SetSiteStatusActivity (archived ⇒ false, active/readonly ⇒ true),
|
|
// and the swap path crosses no servable boundary at all. DeleteSite's permanent
|
|
// delete likewise releases the placement inside DeleteFedWikiSiteActivity.
|
|
// Keeping the registry write beside the status write means a transition can
|
|
// never land locally while the name's servability silently disagrees.
|
|
func (h *FedWikiPartialsHandler) changeSiteStatus(r *http.Request, domain, target string) (errMsg string, pending bool) {
|
|
session := h.AuthConfig.GetUserSession(r.Context())
|
|
if session == nil {
|
|
return "Unauthorized", false
|
|
}
|
|
if domain == "" {
|
|
return "Domain is required", false
|
|
}
|
|
site, err := h.SiteQ.GetSiteByDomain(r.Context(), domain)
|
|
if err != nil {
|
|
return "Site not found", false
|
|
}
|
|
if site.WorkspaceID != session.WorkspaceID {
|
|
// A distinct answer here is an existence oracle for other tenants'
|
|
// domains (security-audit-remediation-2 design D5): answer exactly
|
|
// as the failed-lookup branch above, and keep the distinction in
|
|
// the log only. RestoreSite and KeepSiteActive already gate
|
|
// ownership before calling in, so this branch is reached through
|
|
// ArchiveSite.
|
|
h.Logger.Debug("site status change refused: not the caller's workspace",
|
|
slog.String("domain", domain),
|
|
slog.String("target_status", target),
|
|
slog.String("caller_workspace", session.WorkspaceID),
|
|
slog.String("owner_workspace", site.WorkspaceID))
|
|
return "Site not found", false
|
|
}
|
|
if h.TemporalClient == nil {
|
|
h.Logger.Error("Temporal client not configured")
|
|
return "Site creation service is not available", false
|
|
}
|
|
|
|
we, err := h.TemporalClient.ExecuteWorkflow(r.Context(),
|
|
client.StartWorkflowOptions{ID: "set-fedwiki-status-" + uuid.New().String(), TaskQueue: queues.Main},
|
|
workflows.SetSiteStatusWorkflow,
|
|
workflows.SetSiteStatusWorkflowInput{
|
|
Domain: domain,
|
|
WorkspaceID: session.WorkspaceID,
|
|
CurrentStatus: site.Status,
|
|
TargetStatus: target,
|
|
SupportURL: h.SupportURL,
|
|
})
|
|
if err != nil {
|
|
h.Logger.Error("failed to start status workflow", slog.Any("error", err))
|
|
return "Failed to update the site. Try again.", false
|
|
}
|
|
waitCtx, cancelWait := context.WithTimeout(r.Context(), workflowWaitBudget)
|
|
defer cancelWait()
|
|
var result workflows.SetSiteStatusWorkflowOutput
|
|
if err := we.Get(waitCtx, &result); err != nil {
|
|
if workflowWaitInterrupted(err) {
|
|
h.Logger.Warn("status change outlived the request wait; workflow still running",
|
|
slog.String("workflowID", we.GetID()), slog.Any("error", err))
|
|
return "", true
|
|
}
|
|
h.Logger.Error("status workflow execution failed", slog.Any("error", err))
|
|
return "Failed to update the site. Try again.", false
|
|
}
|
|
if !result.Success {
|
|
return result.ErrorMessage, false
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// workflowWaitBudget caps how long a partial handler blocks on a Temporal
|
|
// workflow before answering "still working" (audit #19). It must sit well
|
|
// inside http.Server's 8s WriteTimeout: a pending response written after that
|
|
// network deadline never reaches the browser.
|
|
const workflowWaitBudget = 6 * time.Second
|
|
|
|
// workflowWaitInterrupted reports whether an error from WorkflowRun.Get means
|
|
// this request stopped waiting (wait budget spent or client disconnect)
|
|
// rather than the workflow failing — the workflow keeps running on its own.
|
|
func workflowWaitInterrupted(err error) bool {
|
|
return errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)
|
|
}
|
|
|
|
// siteActionResult writes the outcome of an archive/restore action: on success an
|
|
// HX-Trigger refreshes the list; on failure an inline alert is returned to the
|
|
// action's target.
|
|
func (h *FedWikiPartialsHandler) siteActionResult(w http.ResponseWriter, errMsg string) {
|
|
if errMsg != "" {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`<div class="alert alert-danger py-2 small mb-2" role="alert">` + template.HTMLEscapeString(errMsg) + `</div>`))
|
|
return
|
|
}
|
|
w.Header().Set("HX-Trigger", `{"refreshSites": true}`)
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// siteActionPending answers an action whose workflow outlived the request's
|
|
// wait window (audit #19 interim guard): honest still-working copy plus a
|
|
// list refresh instead of a false failure, pending a real pending-state
|
|
// lifecycle design.
|
|
func (h *FedWikiPartialsHandler) siteActionPending(w http.ResponseWriter) {
|
|
w.Header().Set("HX-Trigger", `{"refreshSites": true}`)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`<div class="alert alert-info py-2 small mb-2" role="status">Still working; this is taking longer than expected. The site list will update when it finishes; refresh in a minute if it hasn't.</div>`))
|
|
}
|
|
|
|
// ArchiveSite handles POST /partials/fedwiki/sites/{domain}/archive — moves the
|
|
// site to the archived state (recoverable; frees a quota slot).
|
|
func (h *FedWikiPartialsHandler) ArchiveSite(w http.ResponseWriter, r *http.Request) {
|
|
msg, pending := h.changeSiteStatus(r, r.PathValue("domain"), "archived")
|
|
if pending {
|
|
h.siteActionPending(w)
|
|
return
|
|
}
|
|
h.siteActionResult(w, msg)
|
|
}
|
|
|
|
// RestoreSite handles POST /partials/fedwiki/sites/{domain}/restore — returns an
|
|
// archived site to active. Restoring a site that is NOT force-reduced (your own
|
|
// active/archived site — your incumbent) is free. Restoring a force-reduced site
|
|
// (one a downgrade parked) brings a reserve back into the active set, which is a
|
|
// rotation gated by the swap cooldown. Quota-gated either way.
|
|
func (h *FedWikiPartialsHandler) RestoreSite(w http.ResponseWriter, r *http.Request) {
|
|
session := h.AuthConfig.GetUserSession(r.Context())
|
|
if session == nil {
|
|
h.siteActionResult(w, "Unauthorized")
|
|
return
|
|
}
|
|
domain := r.PathValue("domain")
|
|
site, err := h.SiteQ.GetSiteByDomain(r.Context(), domain)
|
|
if err == nil && site.WorkspaceID != session.WorkspaceID {
|
|
// Same answer as the failed lookup below (design D5); the log
|
|
// keeps the distinction for operators.
|
|
h.Logger.Debug("site action refused: not the caller's workspace",
|
|
slog.String("domain", domain),
|
|
slog.String("caller_workspace", session.WorkspaceID),
|
|
slog.String("owner_workspace", site.WorkspaceID))
|
|
h.siteActionResult(w, "Site not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
h.siteActionResult(w, "Site not found")
|
|
return
|
|
}
|
|
// Identity gate: a force-reduced site is a downgrade-parked reserve — bringing
|
|
// it back is a rotation. A non-force-reduced site is the member's own
|
|
// incumbent and restores freely.
|
|
rotation := site.ForceReducedAt.Valid
|
|
if rotation {
|
|
// Claim the cooldown slot atomically (check-and-stamp under a
|
|
// per-workspace advisory lock) before running the workflow, so a
|
|
// second tab cannot also pass the check and double-rotate (audit #51).
|
|
if msg := h.claimRotationSlot(r.Context(), session.WorkspaceID); msg != "" {
|
|
h.siteActionResult(w, msg)
|
|
return
|
|
}
|
|
}
|
|
msg, pending := h.changeSiteStatus(r, domain, "active")
|
|
if pending {
|
|
// Outcome unknown — leave the force-reduced marker alone; a stale
|
|
// marker is corrected by the next reconcile/rotation.
|
|
h.siteActionPending(w)
|
|
return
|
|
}
|
|
if msg == "" && rotation {
|
|
// Cooldown already stamped by claimRotationSlot; the site is now the
|
|
// member's active choice, so drop the downgrade-parked marker.
|
|
h.clearForceReduced(r.Context(), domain)
|
|
}
|
|
h.siteActionResult(w, msg)
|
|
}
|
|
|
|
// KeepSiteActive handles POST /partials/fedwiki/sites/{domain}/keep-active —
|
|
// makes a read-only (force-reduced) site the active one. When the workspace is
|
|
// below its limit this is a plain reactivation; at the limit it is a swap (park
|
|
// the least-recently-modified active site, promote the chosen one) gated by the
|
|
// per-workspace swap cooldown.
|
|
func (h *FedWikiPartialsHandler) KeepSiteActive(w http.ResponseWriter, r *http.Request) {
|
|
session := h.AuthConfig.GetUserSession(r.Context())
|
|
if session == nil {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
domain := r.PathValue("domain")
|
|
chosen, err := h.SiteQ.GetSiteByDomain(r.Context(), domain)
|
|
if err == nil && chosen.WorkspaceID != session.WorkspaceID {
|
|
// Same answer as the failed lookup below (design D5); the log
|
|
// keeps the distinction for operators.
|
|
h.Logger.Debug("site action refused: not the caller's workspace",
|
|
slog.String("domain", domain),
|
|
slog.String("caller_workspace", session.WorkspaceID),
|
|
slog.String("owner_workspace", chosen.WorkspaceID))
|
|
h.siteActionResult(w, "Site not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
h.siteActionResult(w, "Site not found")
|
|
return
|
|
}
|
|
if chosen.Status == "active" {
|
|
h.siteActionResult(w, "") // already active — no-op
|
|
return
|
|
}
|
|
|
|
q, qErr := h.getWorkspaceQuota(r.Context(), session.WorkspaceID)
|
|
active, aErr := h.SiteQ.ListActiveSitesByWorkspace(r.Context(), session.WorkspaceID)
|
|
if aErr != nil {
|
|
h.siteActionResult(w, "Could not load your sites. Try again.")
|
|
return
|
|
}
|
|
belowLimit := qErr != nil || int64(len(active)) < q.resourceLimit
|
|
|
|
// A rotation brings a site into the active set in place of another: any
|
|
// activation at the limit (a swap that parks an active site), or reactivating
|
|
// a force-reduced (downgrade-parked) reserve. Reactivating your own incumbent
|
|
// below the limit is a free fill. Rotations are gated by the swap cooldown.
|
|
rotation := !belowLimit || chosen.ForceReducedAt.Valid
|
|
|
|
// Below the limit → plain reactivation. When it is a rotation, claim the
|
|
// cooldown slot atomically (check-and-stamp under the per-workspace advisory
|
|
// lock) first, so two tabs cannot both pass the check and both rotate.
|
|
if belowLimit {
|
|
if rotation {
|
|
if msg := h.claimRotationSlot(r.Context(), session.WorkspaceID); msg != "" {
|
|
h.siteActionResult(w, msg)
|
|
return
|
|
}
|
|
}
|
|
msg, pending := h.changeSiteStatus(r, domain, "active")
|
|
if pending {
|
|
h.siteActionPending(w)
|
|
return
|
|
}
|
|
if msg == "" && rotation {
|
|
h.clearForceReduced(r.Context(), domain)
|
|
}
|
|
h.siteActionResult(w, msg)
|
|
return
|
|
}
|
|
|
|
// At the limit → swap. Temporal is required; check it up front (read-only) so
|
|
// a provisioning outage does not burn the member's cooldown window.
|
|
if h.TemporalClient == nil {
|
|
h.siteActionResult(w, "Site creation service is not available")
|
|
return
|
|
}
|
|
|
|
// Select the park target and stamp the cooldown atomically under a
|
|
// per-workspace Postgres advisory lock, BEFORE starting the swap workflow
|
|
// (audit #51). Serializing the cooldown check, the park-target selection, and
|
|
// the cooldown stamp means two near-simultaneous tabs can no longer both pass
|
|
// the check, compute the same park target, and double-decrement usage — the
|
|
// second tab now observes the stamped cooldown and is refused. The long
|
|
// workflow wait runs after the lock is released so it is never held across
|
|
// multi-minute farm I/O.
|
|
var parkDomain, swapRefusal string
|
|
if err := h.withWorkspaceLock(r.Context(), session.WorkspaceID, func(ctx context.Context, q fwmod.Querier) error {
|
|
if msg := h.cooldownMsgFor(ctx, q, session.WorkspaceID); msg != "" {
|
|
swapRefusal = msg
|
|
return nil // still cooling down — commit is a no-op, nothing stamped
|
|
}
|
|
locked, err := q.ListActiveSitesByWorkspace(ctx, session.WorkspaceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(locked) == 0 {
|
|
swapRefusal = "Site creation service is not available"
|
|
return nil
|
|
}
|
|
parkDomain = locked[len(locked)-1].Domain // least-recently-modified active
|
|
return q.UpsertSiteSwapPolicy(ctx, session.WorkspaceID)
|
|
}); err != nil {
|
|
h.Logger.Error("failed to claim swap slot", slog.Any("error", err))
|
|
h.siteActionResult(w, "Failed to change the active site. Try again.")
|
|
return
|
|
}
|
|
if swapRefusal != "" {
|
|
h.siteActionResult(w, swapRefusal)
|
|
return
|
|
}
|
|
|
|
we, err := h.TemporalClient.ExecuteWorkflow(r.Context(),
|
|
client.StartWorkflowOptions{ID: "swap-fedwiki-site-" + uuid.New().String(), TaskQueue: queues.Main},
|
|
workflows.SwapActiveSiteWorkflow,
|
|
workflows.SwapActiveSiteInput{
|
|
WorkspaceID: session.WorkspaceID,
|
|
ParkDomain: parkDomain,
|
|
ActivateDomain: domain,
|
|
SupportURL: h.SupportURL,
|
|
})
|
|
if err != nil {
|
|
h.Logger.Error("failed to start swap workflow", slog.Any("error", err))
|
|
h.siteActionResult(w, "Failed to change the active site. Try again.")
|
|
return
|
|
}
|
|
waitCtx, cancelWait := context.WithTimeout(r.Context(), workflowWaitBudget)
|
|
defer cancelWait()
|
|
var result workflows.SwapActiveSiteOutput
|
|
if err := we.Get(waitCtx, &result); err != nil {
|
|
if workflowWaitInterrupted(err) {
|
|
h.Logger.Warn("swap outlived the request wait; workflow still running",
|
|
slog.String("workflowID", we.GetID()), slog.Any("error", err))
|
|
h.siteActionPending(w)
|
|
return
|
|
}
|
|
h.siteActionResult(w, "Failed to change the active site. Try again.")
|
|
return
|
|
}
|
|
if !result.Success {
|
|
msg := "Failed to change the active site. Try again."
|
|
if result.ErrorMessage != "" {
|
|
msg = result.ErrorMessage
|
|
}
|
|
h.siteActionResult(w, msg)
|
|
return
|
|
}
|
|
h.siteActionResult(w, "")
|
|
}
|
|
|
|
// clearForceReduced drops a site's downgrade-parked marker after the member has
|
|
// (re)activated it — it is now their active choice, not a reserve. Best-effort:
|
|
// a failure here only leaves a stale marker the next reconcile/rotation corrects,
|
|
// so it must not fail the user-facing action.
|
|
func (h *FedWikiPartialsHandler) clearForceReduced(ctx context.Context, domain string) {
|
|
if err := h.SiteQ.ClearForceReducedByDomain(ctx, domain); err != nil {
|
|
h.Logger.Warn("failed to clear force_reduced after rotation", slog.Any("error", err))
|
|
}
|
|
}
|
|
|
|
// withWorkspaceLock runs fn inside a transaction that holds a per-workspace
|
|
// Postgres advisory lock (pg_advisory_xact_lock, released on commit/rollback),
|
|
// serializing a workspace's active-site rotations so two tabs cannot interleave
|
|
// the cooldown check-and-stamp or the park-target selection (audit #51). fn
|
|
// receives a transaction-scoped querier; returning an error rolls the
|
|
// transaction back (nothing is stamped), otherwise it commits. This mirrors the
|
|
// advisory-lock idiom already used to serialize concurrent reconciles of one
|
|
// subscription in internal/fulfillment/reconcile.go — the established per-entity
|
|
// serialization pattern in this repo (Temporal workflow-ID conflict policies
|
|
// here guard only singleton daemon workflows, not per-request work).
|
|
func (h *FedWikiPartialsHandler) withWorkspaceLock(ctx context.Context, workspaceID string, fn func(context.Context, fwmod.Querier) error) error {
|
|
if h.Database == nil {
|
|
// No pooled DB to take an advisory lock on (e.g. unit tests) — run
|
|
// without cross-tab serialization rather than panicking on a nil handle.
|
|
return fn(ctx, h.SiteQ)
|
|
}
|
|
tx, err := h.Database.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
// hashtext maps the workspace UUID text onto the bigint the advisory-lock API
|
|
// takes; an occasional hash collision only serializes two unrelated
|
|
// workspaces briefly, which is harmless.
|
|
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext($1)::bigint)`, workspaceID); err != nil {
|
|
return err
|
|
}
|
|
if err := fn(ctx, fwmod.New(tx)); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// claimRotationSlot atomically checks the swap cooldown and, when the rotation is
|
|
// allowed, stamps it — both under the per-workspace advisory lock — so two
|
|
// concurrent tabs cannot both pass the check and both rotate the active slot
|
|
// (audit #51). It returns a user-facing refusal message if the workspace is
|
|
// still cooling down, or "" if the caller now owns the rotation for this window.
|
|
// The stamp lands BEFORE the caller runs its workflow: a later workflow failure
|
|
// burns the window, the accepted trade for never leaving the bypass open. On an
|
|
// infrastructure error it fails closed (refuses) so a lock or DB fault can never
|
|
// reopen that bypass.
|
|
func (h *FedWikiPartialsHandler) claimRotationSlot(ctx context.Context, workspaceID string) string {
|
|
if h.SwapCooldown <= 0 {
|
|
return "" // cooldown disabled → nothing to serialize or stamp
|
|
}
|
|
var refusal string
|
|
if err := h.withWorkspaceLock(ctx, workspaceID, func(ctx context.Context, q fwmod.Querier) error {
|
|
if msg := h.cooldownMsgFor(ctx, q, workspaceID); msg != "" {
|
|
refusal = msg
|
|
return nil // still cooling down — commit is a no-op, nothing stamped
|
|
}
|
|
return q.UpsertSiteSwapPolicy(ctx, workspaceID)
|
|
}); err != nil {
|
|
h.Logger.Error("failed to claim rotation slot", slog.Any("error", err))
|
|
return "We couldn't change which site is active. Try again later."
|
|
}
|
|
return refusal
|
|
}
|
|
|
|
// checkSwapCooldown returns a user-facing message if the workspace's active-site
|
|
// rotation is still within the configured cooldown window, or "" if it is
|
|
// allowed (no prior rotation, the window elapsed, or the cooldown is disabled).
|
|
// It reads through the handler's default querier; the swap path uses
|
|
// cooldownMsgFor to re-check under the transaction that holds the advisory lock.
|
|
func (h *FedWikiPartialsHandler) checkSwapCooldown(ctx context.Context, workspaceID string) string {
|
|
return h.cooldownMsgFor(ctx, h.SiteQ, workspaceID)
|
|
}
|
|
|
|
// cooldownMsgFor is checkSwapCooldown against an explicit querier, so the cooldown
|
|
// re-check can run through the same transaction that holds the per-workspace
|
|
// advisory lock — making the check-and-stamp atomic.
|
|
func (h *FedWikiPartialsHandler) cooldownMsgFor(ctx context.Context, q fwmod.Querier, workspaceID string) string {
|
|
window := h.SwapCooldown
|
|
if window <= 0 {
|
|
return "" // cooldown disabled
|
|
}
|
|
last, err := q.GetSiteSwapPolicy(ctx, workspaceID)
|
|
if err != nil {
|
|
return "" // no prior rotation → free
|
|
}
|
|
elapsed := time.Since(last)
|
|
if elapsed >= window {
|
|
return ""
|
|
}
|
|
windowDays := int(window.Hours()/24 + 0.5)
|
|
if windowDays < 1 {
|
|
windowDays = 1
|
|
}
|
|
remainingDays := int((window-elapsed).Hours()/24) + 1
|
|
return fmt.Sprintf("The active site changes once every %d day(s); try again in about %d day(s).", windowDays, remainingDays)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// renderCreateFormRefusal re-renders the create form at 422 with the
|
|
// declaration in submission mode: every submitted value carried back, each
|
|
// field's error under its control, and a refusal that belongs to no field
|
|
// in the form-level slot the part always renders (design D9; findings
|
|
// FA-17, FA-22, FA-27).
|
|
func (h *FedWikiPartialsHandler) renderCreateFormRefusal(w http.ResponseWriter, r *http.Request, workspaceID string, values forms.Values, errs *forms.Errors) {
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
|
|
customDomains, commitState := h.resolveCreateFormAffordances(r.Context(), workspaceID)
|
|
// customDomainsEnabled is set fresh from the workspace's current state,
|
|
// never read back from the submission (finding FA-10): a refused
|
|
// resubmission must not let a stale or tampered value reopen an
|
|
// affordance the workspace does not currently have.
|
|
enabledRaw := ""
|
|
if customDomains {
|
|
enabledRaw = "true"
|
|
}
|
|
values.Set("customDomainsEnabled", enabledRaw)
|
|
|
|
view := forms.Render(siteCreateForm, forms.Binding{
|
|
Mode: forms.ModeSubmission,
|
|
Values: values,
|
|
Errors: errs,
|
|
Options: siteCreateOptions(h.FedWikiAllowedDomains),
|
|
})
|
|
view.CommitState = commitState
|
|
|
|
h.Templates.Render(w, "fedwiki_create_form.html", CreateFormData{
|
|
AllowedDomains: h.FedWikiAllowedDomains,
|
|
Form: view,
|
|
})
|
|
}
|
|
|
|
func (h *FedWikiPartialsHandler) getWorkspaceQuota(ctx context.Context, workspaceID string) (*workspaceQuota, error) {
|
|
assignment, err := h.EntitlementsQ.GetPrimaryPoolAssignmentByWorkspace(ctx, workspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ent, err := h.EntitlementsQ.GetNumericEntitlementByPoolAndResource(ctx, entitlements.GetNumericEntitlementByPoolAndResourceParams{
|
|
PoolID: assignment.PoolID,
|
|
ResourceKey: siteusage.ResourceKey,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Rows, not the reservation counter, are what the member is shown
|
|
// (design D1): the counter is a create-time reservation and an import or
|
|
// a farm sync writes rows without touching it.
|
|
// The pool and entitlement lookups above run first on purpose: a handler
|
|
// built without a site store (the zero-domains render tests) fails on the
|
|
// missing pool row instead of dereferencing a nil SiteQ here.
|
|
current, err := siteusage.ActiveSites(ctx, h.SiteQ, workspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &workspaceQuota{
|
|
currentUsage: current,
|
|
resourceLimit: ent.ResourceLimit,
|
|
}, nil
|
|
}
|