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 = `

Modules

{{if .NoticeText}}

{{.NoticeText}}

{{end}} {{if .RestartNeeded}}

A change is pending — restart MyDay to apply it.

{{end}} {{range .Rows}} {{end}}
ModuleKindRequiredEnabled
{{.Name}} {{.Kind}} {{if .Required}}yes{{else}}no{{end}} {{if .Enabled}}yes{{else}}no{{end}}{{if ne .Enabled .Wanted}} (wanted: {{if .Wanted}}yes{{else}}no{{end}}){{end}} {{if .Locked}}locked {{else}}
{{if .Wanted}} {{else}}{{end}}
{{end}}
` // modulesTemplate is parsed once; a parse failure is impossible for a constant. var modulesTemplate = template.Must(template.New("modules").Parse(modulesTemplateHTML))