Files
cgalo5758 88db730fcc Add dual licensing and SPDX headers
Introduce a commercial license option alongside AGPL-3.0-only, require a
CLA for contributors, and document the terms in COMMERCIAL.md and
NOTICE. Add a script to stamp SPDX headers on Go files and apply it
across the tree.
2026-09-06 02:29:42 -05:00

468 lines
16 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package web
import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
dcclient "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/client"
dcmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows"
temporalsdk "go.temporal.io/sdk/client"
)
// groupMappingConstraints translates this form's plausible constraint
// violations (docs/operator-ux-conventions.md §6 — names live next to the
// form they belong to).
var groupMappingConstraints = web.ConstraintMessages{
"uq_group_mappings_group_name": {Field: "group_name", Message: "That group is already managed by another mapping."},
"fk_group_mappings_resource_key": {Field: "resource_key", Message: "Unknown resource key."},
}
// MappingViewModel is one entitlement→group mapping row.
type MappingViewModel struct {
MappingID string
ResourceKey string
// DisplayName is the resource key's friendly name, from the same
// resource-key catalog the mapping-form picker uses, so the mappings
// table leads with it instead of the raw key (ui-vocabulary 4.3).
DisplayName string
GroupName string
GroupID int64
CreatedAt string
DeleteURL string
}
// UnlinkedPersonViewModel is one entitled-but-unlinked person.
type UnlinkedPersonViewModel struct {
DisplayName string
Email string
}
// ConflictViewModel is one conflicted link.
type ConflictViewModel struct {
DiscourseUsername string
PersonID string
}
// ResourceKeyOption is a select option for the mapping form.
type ResourceKeyOption struct {
Key string
DisplayName string
}
// OperatorPageData is the discourse_operator.html body payload.
type OperatorPageData struct {
Configured bool
// Description, when the provider manifest declares one, overrides the
// page header's default lead (operator-panel-navigation: "A provider
// page leads with the integration's description").
Description string
// Sync health (latest sweep execution, from Temporal).
SweepStatus string
SweepTime string
Mappings []MappingViewModel
ResourceKeys []ResourceKeyOption
Unlinked []UnlinkedPersonViewModel
Conflicts []ConflictViewModel
// Form is the mapping declaration, unbound on a plain render or bound
// to a refused submission of it (form-library).
Form forms.FormView
Error string
}
// DiscourseOperatorHandler serves the Discourse integration's operator
// surface: entitlement→group mapping management and link/sync visibility
// (spec: discourse-operator-surface).
type DiscourseOperatorHandler struct {
db *sql.DB
logger *slog.Logger
authConfig *auth.Config
apiClient *dcclient.Client
temporal temporalsdk.Client
configured bool
description string
sweepScheduleID string
templates *server.SafeTemplates
}
// DiscourseOperatorHandlerConfig configures the handler.
type DiscourseOperatorHandlerConfig struct {
DB *sql.DB
Logger *slog.Logger
AuthConfig *auth.Config
// Client is nil when the integration is unconfigured (the page then
// renders only the unconfigured notice; mutation routes are unmounted).
Client *dcclient.Client
Temporal temporalsdk.Client
Configured bool
// Description: see OperatorPageData.Description.
Description string
// TemplatesFS is the integration's own template tree, injected by the
// adapter (same cycle argument as FedWiki's operator handler).
TemplatesFS fs.FS
// SweepScheduleID names the sweep SCHEDULE to report health from
// (this integration's workflows.SyncScheduleID; injected so this
// package needs no import of its own workflows package). Runs are
// resolved through the schedule because schedule-spawned executions
// get timestamp-suffixed workflow IDs — see
// workflows.ScheduleRunStatus.
SweepScheduleID string
}
// NewDiscourseOperatorHandler composes the operator shell with the
// integration's body partial.
func NewDiscourseOperatorHandler(cfg DiscourseOperatorHandlerConfig) (*DiscourseOperatorHandler, error) {
shellFS, err := fs.Sub(embeds.Templates, "templates")
if err != nil {
return nil, err
}
var tmpl *template.Template
tmpl = template.New("discourse-operator").Funcs(template.FuncMap{
"renderBody": func(name string, data any) (template.HTML, error) {
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
return "", err
}
return template.HTML(buf.String()), nil
},
"deploymentName": config.DeploymentName,
// The shell's <title> names the page (server.PageTitle).
"pageTitle": server.PageTitle,
})
// The shell's landing branch references operator_lookup_result.html;
// html/template's escape analysis requires every referenced template to
// be in the set even though BodyTemplate pages never execute that
// branch — parse it alongside the shell (core's operator.go does the
// same; FedWiki's operator handler omits it and 500s — findings.md #8).
tmpl, err = tmpl.ParseFS(shellFS, "operator.html", "partials/operator_lookup_result.html", "partials/operator_setup.html")
if err != nil {
return nil, err
}
if tmpl, err = web.ParseUIPartials(tmpl); err != nil {
return nil, err
}
// An integration's operator page is an operator page: its trail is
// rooted at the operator surface like every other (design D18).
tmpl = tmpl.Funcs(template.FuncMap{"surfaceRoot": web.OperatorSurfaceRoot})
tmpl, err = tmpl.ParseFS(cfg.TemplatesFS, "discourse_operator.html")
if err != nil {
return nil, err
}
return &DiscourseOperatorHandler{
db: cfg.DB,
logger: cfg.Logger,
authConfig: cfg.AuthConfig,
apiClient: cfg.Client,
temporal: cfg.Temporal,
configured: cfg.Configured,
description: cfg.Description,
sweepScheduleID: cfg.SweepScheduleID,
templates: server.NewSafeTemplates(tmpl, cfg.Logger),
}, nil
}
// buildPageData assembles the body payload. API/Temporal-dependent sections
// are only populated when configured (spec: the unconfigured page shows no
// error-state sync data).
func (h *DiscourseOperatorHandler) buildPageData(r *http.Request) OperatorPageData {
data := OperatorPageData{Configured: h.configured, Description: h.description}
if !h.configured {
return data
}
ctx := r.Context()
q := dcmod.New(h.db)
// Fetched before the mappings loop so the same catalog backs both the
// picker's friendly names and each mapping row's leading display name
// (ui-vocabulary 4.3) — one lookup, two consumers.
keys, err := q.ListOwnedResourceKeys(ctx)
if err != nil {
h.logger.Error("discourse operator: resource keys query failed", slog.Any("error", err))
}
displayNames := make(map[string]string, len(keys))
for _, k := range keys {
displayNames[k.ResourceKey] = k.DisplayName
data.ResourceKeys = append(data.ResourceKeys, ResourceKeyOption{Key: k.ResourceKey, DisplayName: k.DisplayName})
}
mappings, err := q.ListGroupMappings(ctx)
if err != nil {
h.logger.Error("discourse operator: list mappings failed", slog.Any("error", err))
data.Error = "Failed to load group mappings. Details are in the server logs."
return data
}
seenKeys := map[string]bool{}
seenPersons := map[string]bool{}
for _, m := range mappings {
data.Mappings = append(data.Mappings, MappingViewModel{
MappingID: m.MappingID,
ResourceKey: m.ResourceKey,
DisplayName: displayNames[m.ResourceKey],
GroupName: m.GroupName,
GroupID: m.DiscourseGroupID,
CreatedAt: m.CreatedAt.Format("Jan 2, 2006"),
DeleteURL: "/partials/operator/discourse/mappings/" + m.MappingID,
})
if seenKeys[m.ResourceKey] {
continue
}
seenKeys[m.ResourceKey] = true
unlinked, uErr := q.ListEntitledUnlinkedPersons(ctx, m.ResourceKey)
if uErr != nil {
h.logger.Error("discourse operator: unlinked query failed", slog.Any("error", uErr))
continue
}
for _, p := range unlinked {
if seenPersons[p.PersonID] {
continue
}
seenPersons[p.PersonID] = true
data.Unlinked = append(data.Unlinked, UnlinkedPersonViewModel{
DisplayName: p.DisplayName, Email: p.PrimaryEmail,
})
}
}
conflicts, err := q.ListConflictedUserLinks(ctx)
if err != nil {
h.logger.Error("discourse operator: conflicts query failed", slog.Any("error", err))
}
for _, c := range conflicts {
data.Conflicts = append(data.Conflicts, ConflictViewModel{
DiscourseUsername: c.DiscourseUsername, PersonID: c.PersonID,
})
}
data.SweepStatus, data.SweepTime = h.sweepHealth(r)
data.Form = forms.Render(mappingForm, forms.Binding{
Mode: forms.ModeUnbound,
Options: mappingFormOptions(data.ResourceKeys),
})
return data
}
// sweepHealth reports the most recent schedule-spawned sweep's status and
// time; "no sweep yet" when the schedule hasn't fired or Temporal is absent.
func (h *DiscourseOperatorHandler) sweepHealth(r *http.Request) (string, string) {
status, when := workflows.ScheduleRunStatus(r.Context(), h.temporal, h.sweepScheduleID)
if status == "" {
return "No sweep has run yet", ""
}
return status, when
}
// GetPage handles GET <OperatorSurfacePath>.
func (h *DiscourseOperatorHandler) GetPage(w http.ResponseWriter, r *http.Request) {
page := server.BuildOperatorPageData(r, h.authConfig, h.db, h.logger)
// Second-level page of the Integrations section: the section's sidebar
// entry lights up via ActiveCapability (the page is reached from the
// Integrations home, not the sidebar); ia-position nests under the landing.
page.IAPosition = "integration:integrations:discourse"
page.ActiveCapability = "integrations"
page.BodyTemplate = "discourse_operator.html"
page.BodyData = h.buildPageData(r)
h.templates.Render(w, "operator.html", page)
}
// renderBody renders the body partial alone (HTMX swap responses).
func (h *DiscourseOperatorHandler) renderBody(w http.ResponseWriter, status int, data OperatorPageData) {
w.WriteHeader(status)
h.templates.Render(w, "discourse_operator.html", data)
}
// CreateMapping handles POST /partials/operator/discourse/mappings: validate
// against the forum (group exists, not automatic), then insert (spec:
// "Entitlement-to-group mapping management"). It reads the body through the
// mapping declaration and never through r.FormValue (form-library "The
// handler parses through the declaration"): a resource key outside the
// options the select offered is refused by Parse itself, never resolved to
// a default (finding FA-8).
func (h *DiscourseOperatorHandler) CreateMapping(w http.ResponseWriter, r *http.Request) {
keys, err := dcmod.New(h.db).ListOwnedResourceKeys(r.Context())
if err != nil {
h.logger.Error("discourse operator: resource keys query failed", slog.Any("error", err))
}
var resourceKeys []ResourceKeyOption
for _, k := range keys {
resourceKeys = append(resourceKeys, ResourceKeyOption{Key: k.ResourceKey, DisplayName: k.DisplayName})
}
options := mappingFormOptions(resourceKeys)
values, errs := mappingForm.ParseWith(r, options)
fail := func() {
data := h.buildPageData(r)
data.Form = forms.Render(mappingForm, forms.Binding{
Mode: forms.ModeSubmission,
Values: values,
Errors: errs,
Options: options,
})
h.renderBody(w, http.StatusUnprocessableEntity, data)
}
if errs.Any() {
fail()
return
}
resourceKey := values.String("resource_key")
groupName := values.String("group_name")
group, err := h.apiClient.GetGroup(r.Context(), groupName)
if err != nil {
h.logger.Error("discourse operator: group validation failed", slog.Any("error", err))
errs.Field("group_name", "Could not reach the forum to validate the group. Try again.")
fail()
return
}
if group == nil {
errs.Field("group_name", "No group with that name exists on the forum.")
fail()
return
}
if group.Automatic {
errs.Field("group_name", "That is an automatic Discourse group; its membership cannot be managed externally. Choose a manually-created group.")
fail()
return
}
if _, err := dcmod.New(h.db).CreateGroupMapping(r.Context(), dcmod.CreateGroupMappingParams{
ResourceKey: resourceKey, GroupName: group.Name, DiscourseGroupID: group.ID,
}); err != nil {
if fe, ok := web.FieldErrorsFromDB(err, groupMappingConstraints); ok {
for field, msg := range fe {
errs.Field(field, msg)
}
fail()
return
}
h.logger.Error("discourse operator: create mapping failed", slog.Any("error", err))
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
h.fireToast(w, "Group mapping created")
h.renderBody(w, http.StatusOK, h.buildPageData(r))
}
// DeleteMapping handles DELETE /partials/operator/discourse/mappings/{mappingID}.
// Unmapping stops management; forum-side membership is left as-is (spec:
// "Unmapping a group").
func (h *DiscourseOperatorHandler) DeleteMapping(w http.ResponseWriter, r *http.Request) {
rows, err := dcmod.New(h.db).DeleteGroupMapping(r.Context(), r.PathValue("mappingID"))
if err != nil {
h.logger.Error("discourse operator: delete mapping failed", slog.Any("error", err))
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if rows == 0 {
http.Error(w, "Not found", http.StatusNotFound)
return
}
h.fireToast(w, "Group mapping deleted")
h.renderBody(w, http.StatusOK, h.buildPageData(r))
}
func (h *DiscourseOperatorHandler) fireToast(w http.ResponseWriter, message string) {
if payload, err := json.Marshal(map[string]string{"showSuccessToast": message}); err == nil {
w.Header().Set("HX-Trigger", string(payload))
}
}
// ---- Page-anatomy pipeline (anatomy sweep group 9). Same shape as the
// FedWiki operator page: the shared parts come from web.ParseUIPartials,
// the data uses the server package's part types so badge labels and
// tones stay in the one badge map (design D3).
func (d OperatorPageData) Header() server.PageHeader {
lead := "Delivery of forum access to entitled members: group mappings, member-to-forum account links, and sync health."
if d.Description != "" {
lead = d.Description
}
return server.PageHeader{
Title: "Discourse forum",
Lead: lead,
Crumbs: []server.Link{{Label: "Integrations", URL: "/operator/integrations"}},
Action: &server.Link{Label: "Settings", URL: "/operator/integrations/discourse/settings"},
}
}
// NotConfigured is the page's blocked state: the integration is
// registered but dormant until its keys are set, and the settings page
// is where an operator sees which keys those are.
func (OperatorPageData) NotConfigured() server.EmptyStateParams {
return server.EmptyStateParams{
Blocked: true,
BlockerCopy: "Discourse is not configured. Set discourse-base-url and discourse-api-key (and optionally discourse-webhook-secret) to enable the integration; until then it is registered but dormant.",
PrerequisiteURL: "/operator/integrations/discourse/settings",
PrerequisiteLabel: "Open Discourse settings",
}
}
func (OperatorPageData) SweepHeader() server.SectionHeader {
return server.SectionHeader{Title: "Sync health", Help: "The sweep is the periodic background job that reconciles forum group membership with entitlements."}
}
func (OperatorPageData) MapGroupHeader() server.SectionHeader {
return server.SectionHeader{Title: "Map a group"}
}
func (d OperatorPageData) MappingsHeader() server.SectionHeader {
h := server.SectionHeader{Title: "Group mappings"}
switch n := len(d.Mappings); {
case n == 1:
h.Count = "1 mapping"
case n > 1:
h.Count = fmt.Sprintf("%d mappings", n)
}
return h
}
func (OperatorPageData) NoMappings() server.EmptyStateParams {
return server.EmptyStateParams{
Headline: "No groups are mapped yet.",
Note: "Map an entitlement to a Discourse group above to start delivering forum access.",
}
}
func (d OperatorPageData) UnlinkedHeader() server.SectionHeader {
h := server.SectionHeader{Title: "Entitled but unlinked"}
switch n := len(d.Unlinked); {
case n == 1:
h.Count = "1 person"
case n > 1:
h.Count = fmt.Sprintf("%d people", n)
}
return h
}
func (d OperatorPageData) ConflictsHeader() server.SectionHeader {
h := server.SectionHeader{Title: "Link conflicts"}
switch n := len(d.Conflicts); {
case n == 1:
h.Count = "1 conflict"
case n > 1:
h.Count = fmt.Sprintf("%d conflicts", n)
}
return h
}