- Build-time discovery (D-028, the Caddy pattern): a module self-registers from init(); internal/modules/imports_gen.go (go:generate) blank-imports every module directory, so THE DIRECTORY IS THE SOURCE OF TRUTH - The registry: deterministic registration order, config-backed enable/disable (D-038), required modules, name-prefixed migrations, Mount() into the door - The name-collision guard now also fails a module named for a reserved level-1 workflow (SPEC-009 §13 Q-1) — access · static · api · media · ws · dev — derived from routing.ReservedWorkflows, `home` excepted (D-2) - home: the reference dashboard module, and the core Modules page
326 lines
10 KiB
Go
326 lines
10 KiB
Go
package module
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config"
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server"
|
|
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web"
|
|
)
|
|
|
|
// The Modules page is a CORE page (D-038), not a module: it lists the
|
|
// discovered modules and enables/disables them. It rides the door under a
|
|
// dedicated core dispatch module so the door still gates it (session + role +
|
|
// CSRF) exactly like any other function.
|
|
const (
|
|
// ConsoleModule is the `do`/PARAM2 module carrying the core Modules page.
|
|
ConsoleModule = "core"
|
|
// FunctionModules is the path function (GET /{GUID}/core/modules).
|
|
FunctionModules = "modules"
|
|
// FunctionToggle is the action function
|
|
// (POST /{GUID}?do=core&action=modules.toggle).
|
|
FunctionToggle = "modules.toggle"
|
|
)
|
|
|
|
// Outcome notices the toggle reports.
|
|
const (
|
|
noticeSaved = "saved_restart_required"
|
|
noticeEditAndRestart = "edit_and_restart"
|
|
)
|
|
|
|
// PageDeps assembles the Modules page.
|
|
type PageDeps struct {
|
|
// Registry is the runtime module registry (list + required flags).
|
|
Registry *Registry
|
|
// Config is the loaded configuration; its `modules:` map is the desired
|
|
// state the page edits (D-035/D-038).
|
|
Config *config.Config
|
|
// ConfigPath is where the toggles are persisted (usually config.yaml,
|
|
// never the committed example).
|
|
ConfigPath string
|
|
// WriteFile writes the config (tests inject a failing writer to exercise
|
|
// the "edit and restart" path). Defaults to os.WriteFile 0600.
|
|
WriteFile func(path string, data []byte) error
|
|
// Audit receives the enable/disable events (A11, AC-6).
|
|
Audit audit.Audit
|
|
// Logger receives structured logs. Defaults to slog.Default().
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
// Page renders and toggles the core Modules page.
|
|
type Page struct {
|
|
d PageDeps
|
|
mu sync.Mutex // guards the config map read/modify/write
|
|
}
|
|
|
|
// NewPage validates and builds the Modules page.
|
|
func NewPage(d PageDeps) (*Page, error) {
|
|
if d.Registry == nil {
|
|
return nil, errors.New("module: Modules page requires a Registry")
|
|
}
|
|
if d.Config == nil {
|
|
return nil, errors.New("module: Modules page requires a Config")
|
|
}
|
|
if strings.TrimSpace(d.ConfigPath) == "" {
|
|
return nil, errors.New("module: Modules page requires a ConfigPath")
|
|
}
|
|
if d.WriteFile == nil {
|
|
d.WriteFile = func(path string, data []byte) error { return os.WriteFile(path, data, 0o600) }
|
|
}
|
|
if d.Logger == nil {
|
|
d.Logger = slog.Default()
|
|
}
|
|
return &Page{d: d}, nil
|
|
}
|
|
|
|
// Register mounts the page into the door registry: the list is a PATH route
|
|
// (GET /{GUID}/core/modules) and the toggle is an ACTION
|
|
// (POST /{GUID}?do=core&action=modules.toggle), which the door CSRF-protects.
|
|
//
|
|
// The caller must require the admin role for both (server.Tiers); the page does
|
|
// not decide its own access control.
|
|
func (p *Page) Register(reg *server.MapRegistry) {
|
|
if reg == nil {
|
|
return
|
|
}
|
|
reg.RegisterModule(server.NewModule(ConsoleModule).
|
|
AddRoute(FunctionModules, FunctionModules, p.List).
|
|
AddAction(FunctionToggle, FunctionToggle, p.Toggle))
|
|
}
|
|
|
|
// List renders the Modules page (GET /{GUID}/core/modules).
|
|
func (p *Page) List(w http.ResponseWriter, r *http.Request, rc *server.RequestContext) error {
|
|
return p.render(w, r, rc, "", "")
|
|
}
|
|
|
|
// Toggle saves a module's enabled state to the config
|
|
// (POST /{GUID}?do=core&action=modules.toggle). A required module is refused
|
|
// (AC-4); an unwritable config reports "edit and restart" (D-035). The change
|
|
// is applied at the NEXT start (D-038: no live remount).
|
|
func (p *Page) Toggle(w http.ResponseWriter, r *http.Request, rc *server.RequestContext) error {
|
|
ctx := r.Context()
|
|
name := strings.TrimSpace(r.FormValue("module"))
|
|
if name == "" {
|
|
p.record(ctx, "module.toggle", "", "bad_request")
|
|
writeError(w, http.StatusBadRequest, "bad request")
|
|
return nil
|
|
}
|
|
|
|
m, ok := p.d.Registry.Get(name)
|
|
if !ok {
|
|
p.record(ctx, "module.toggle", name, "unknown")
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return nil
|
|
}
|
|
on := parseEnabled(r.FormValue("enabled"))
|
|
|
|
if !on && m.Required() {
|
|
p.record(ctx, "module.toggle", name, "refused_required")
|
|
writeError(w, http.StatusForbidden, "required modules cannot be disabled")
|
|
return nil
|
|
}
|
|
|
|
notice, err := p.save(name, on)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.record(ctx, auditAction(on), name, notice)
|
|
return p.render(w, r, rc, notice, name)
|
|
}
|
|
|
|
// save writes the desired state to the config file. It returns a human notice
|
|
// describing the outcome. A marshal failure is returned (500); a write failure
|
|
// is reported as "edit and restart" and is NOT fatal (D-035).
|
|
func (p *Page) save(name string, on bool) (string, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
c := *p.d.Config
|
|
if c.Modules == nil {
|
|
c.Modules = make(map[string]bool)
|
|
}
|
|
// c.Modules aliases the live config's map, so consecutive toggles compose
|
|
// instead of clobbering one another. The mounted (running) registry is
|
|
// unaffected: it was latched at startup (D-038).
|
|
c.Modules[name] = on
|
|
|
|
data, err := config.Marshal(&c)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := p.d.WriteFile(p.d.ConfigPath, data); err != nil {
|
|
p.d.Logger.Warn("module: config file is not writable — edit and restart",
|
|
"path", p.d.ConfigPath, "error", err)
|
|
return noticeEditAndRestart, nil
|
|
}
|
|
return noticeSaved, nil
|
|
}
|
|
|
|
// render draws the page inside the core shell (SPEC-009 §4/§7). notice names an
|
|
// outcome banner ("" for none).
|
|
func (p *Page) render(w http.ResponseWriter, r *http.Request, rc *server.RequestContext, notice, changed string) error {
|
|
p.mu.Lock()
|
|
rows := p.rowsLocked()
|
|
restart := false
|
|
for _, row := range rows {
|
|
if row.Wanted != row.Enabled {
|
|
restart = true
|
|
break
|
|
}
|
|
}
|
|
p.mu.Unlock()
|
|
|
|
data := pageData{
|
|
Rows: rows,
|
|
Token: httpx.TokenFromRequest(r),
|
|
NoticeText: noticeText(notice, changed),
|
|
RestartNeeded: restart,
|
|
}
|
|
|
|
var body bytes.Buffer
|
|
if err := modulesTemplate.Execute(&body, data); err != nil {
|
|
return err
|
|
}
|
|
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
return web.Render(w, http.StatusOK, web.Page{
|
|
Title: "Modules",
|
|
GUID: rc.Workflow,
|
|
Active: ConsoleModule,
|
|
CSRF: data.Token,
|
|
Content: web.Markup(body.String()),
|
|
})
|
|
}
|
|
|
|
// rowsLocked snapshots what the page lists. The caller holds p.mu.
|
|
func (p *Page) rowsLocked() []row {
|
|
out := make([]row, 0, len(p.d.Registry.Modules()))
|
|
for _, m := range p.d.Registry.Modules() {
|
|
name := m.Name()
|
|
wanted := true
|
|
if !m.Required() {
|
|
if v, ok := p.d.Config.Modules[name]; ok {
|
|
wanted = v
|
|
}
|
|
}
|
|
out = append(out, row{
|
|
Name: name,
|
|
Kind: KindOf(m),
|
|
Required: m.Required(),
|
|
Enabled: p.d.Registry.Enabled(name),
|
|
Wanted: wanted,
|
|
Locked: m.Required(),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// record writes one audit row for a toggle (A11, AC-6). The door attached the
|
|
// actor (the admin) to the context.
|
|
func (p *Page) record(ctx context.Context, action, name, outcome string) {
|
|
if p.d.Audit == nil {
|
|
return
|
|
}
|
|
p.d.Audit.Record(ctx, audit.Event{
|
|
Action: action,
|
|
Subject: "module:" + name,
|
|
Detail: outcome,
|
|
})
|
|
}
|
|
|
|
// auditAction names the audit event for an enable/disable (A11).
|
|
func auditAction(on bool) string {
|
|
if on {
|
|
return "module.enable"
|
|
}
|
|
return "module.disable"
|
|
}
|
|
|
|
// parseEnabled reads a form's desired state. Absent/unknown means OFF (the
|
|
// safest default for a disable gate).
|
|
func parseEnabled(v string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
|
case "1", "true", "on", "yes", "enable", "enabled":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// noticeText renders the outcome banner.
|
|
func noticeText(notice, changed string) string {
|
|
switch notice {
|
|
case noticeSaved:
|
|
return "Saved " + changed + " to config.yaml. Restart MyDay to apply the change."
|
|
case noticeEditAndRestart:
|
|
return "Could not write config.yaml. Edit it by hand and restart MyDay to apply the change."
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// writeError writes a terse plain-text error response (matching the door's).
|
|
// It is deliberately void: a handler that has already written its response must
|
|
// return nil, or the door would append a second (500) response on a write error.
|
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write([]byte(msg + "\n"))
|
|
}
|
|
|
|
// pageData is the template's input.
|
|
type pageData struct {
|
|
Rows []row
|
|
Token string
|
|
NoticeText string
|
|
RestartNeeded bool
|
|
}
|
|
|
|
// row is one module's line on the page.
|
|
type row struct {
|
|
Name string
|
|
Kind string
|
|
Required bool
|
|
Enabled bool // running (mounted now)
|
|
Wanted bool // desired per config (applies at restart)
|
|
Locked bool
|
|
}
|
|
|
|
// modulesTemplateHTML is the page BODY only: the shell (SPEC-009 §4) owns the
|
|
// document, the header and the nav; this is the content slot.
|
|
const modulesTemplateHTML = `<h1>Modules</h1>
|
|
{{if .NoticeText}}<p class="notice">{{.NoticeText}}</p>{{end}}
|
|
{{if .RestartNeeded}}<p class="restart">A change is pending — restart MyDay to apply it.</p>{{end}}
|
|
<table>
|
|
<thead><tr><th>Module</th><th>Kind</th><th>Required</th><th>Enabled</th><th></th></tr></thead>
|
|
<tbody>
|
|
{{range .Rows}}<tr>
|
|
<td>{{.Name}}</td>
|
|
<td>{{.Kind}}</td>
|
|
<td>{{if .Required}}yes{{else}}no{{end}}</td>
|
|
<td>{{if .Enabled}}yes{{else}}no{{end}}{{if ne .Enabled .Wanted}} (wanted: {{if .Wanted}}yes{{else}}no{{end}}){{end}}</td>
|
|
<td>
|
|
{{if .Locked}}<span class="locked">locked</span>
|
|
{{else}}<form method="post" action="?do=core&action=modules.toggle">
|
|
<input type="hidden" name="csrf" value="{{$.Token}}">
|
|
<input type="hidden" name="module" value="{{.Name}}">
|
|
{{if .Wanted}}<button name="enabled" value="false">Disable</button>
|
|
{{else}}<button name="enabled" value="true">Enable</button>{{end}}
|
|
</form>{{end}}
|
|
</td>
|
|
</tr>
|
|
{{end}}</tbody>
|
|
</table>
|
|
`
|
|
|
|
// modulesTemplate is parsed once; a parse failure is impossible for a constant.
|
|
var modulesTemplate = template.Must(template.New("modules").Parse(modulesTemplateHTML))
|