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.
468 lines
16 KiB
Go
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
|
|
}
|