Step 6/12 of docs/plans/inbound-antispam-panel.md. Written by a Sonnet
subagent from the mockups, reviewed and corrected by me.
GET /inbound/log - the journal a page at a time (50), filtered by
domain, decision and day; QueryInboundSpam / CountInboundSpam with the
viewer's inbound domains as the scope. The global role reads all of it
and gets "Deny sender" / "Allow sender" links into the lists; a user
with the domain role reads the rows of the inbound domains assigned to
them, without the links. The plan said global-only for the journal;
the accepted mockup and 2.0's delegation of Inbound say otherwise -
recorded in the plan as built.
GET, POST /inbound/filter-lists and POST .../{id}/delete - global role,
404 otherwise. Add form (prefilled from the query), the two lists with
Remove as the kit's row action, and "Where the lists go" with the
filter's state and when the maps were last written
(Lists.LastWritten). A refused or duplicate entry is 422 with the
message and what was typed.
Server > Settings: "Keep inbound log rows, days" with the relay on.
Help: Inbound log, Filter lists. Navigation: Log for everyone who sees
Inbound, Filter lists for the global role (Shell.ShowFilterLists).
Status tags accept / reject / tempfail join the kit's mapping.
Tests: page fixtures for both screens, held to outlines.json; view and
handler tests (both roles, filters, paging, relay off, add / duplicate
/ invalid / delete, map file content, settings field, help). e2e reads
the journal from the page instead of the database and adds and removes
a deny entry through the panel, reading deny_domain.map after each.
Seen: both pages and Settings rendered from their fixtures and compared
with the mockups side by side (headless Edge, 1360 px) - same layout
and wording.
Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
188 lines
7.1 KiB
Go
188 lines
7.1 KiB
Go
// Package view embeds the panel's HTML templates and static assets and renders
|
|
// pages and HTMX polling fragments.
|
|
package view
|
|
|
|
import (
|
|
"bytes"
|
|
"embed"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
"path"
|
|
|
|
"github.com/mixeme/selfpost/internal/legal"
|
|
)
|
|
|
|
//go:embed templates/*.html static/*
|
|
var assetsFS embed.FS
|
|
|
|
// Engine holds parsed page and fragment templates.
|
|
type Engine struct {
|
|
pages map[string]*template.Template
|
|
fragments map[string]*template.Template
|
|
version string
|
|
inboundEnabled bool
|
|
dmarcEnabled bool
|
|
}
|
|
|
|
// pageFiles maps a logical page name to its template files. Every page is built
|
|
// from the component kit: layout.html and components.html, then the page's own
|
|
// file. Pages that embed a polling fragment (architecture.md § Panel HTTP
|
|
// surface) list that fragment's file too, so the same {{define}} block renders
|
|
// both the initial page and the fragment's own refresh responses identically.
|
|
// The guard tests read this map.
|
|
var pageFiles = map[string][]string{
|
|
"setup": {"templates/setup.html"},
|
|
"login": {"templates/login.html"},
|
|
"overview": {"templates/overview.html", "templates/overview_cards.html"},
|
|
"health": {"templates/health.html", "templates/health_body.html"},
|
|
"account": {"templates/account.html"},
|
|
"settings": {"templates/settings.html"},
|
|
"system-log": {"templates/system_log.html", "templates/system_log_body.html"},
|
|
"backup": {"templates/backup.html"},
|
|
"users": {"templates/users.html"},
|
|
"user": {"templates/user.html"},
|
|
"user-delete": {"templates/user_delete.html"},
|
|
"help": {"templates/help.html"},
|
|
"out-domains": {"templates/out_domains.html"},
|
|
"out-domain": {"templates/out_domain.html"},
|
|
"out-domain-settings": {"templates/out_domain_settings.html"},
|
|
"out-app": {"templates/out_app.html"},
|
|
"out-app-created": {"templates/out_app_created.html"},
|
|
"out-domain-delete": {"templates/out_domain_delete.html"},
|
|
"in-domains": {"templates/in_domains.html"},
|
|
"in-domain": {"templates/in_domain.html"},
|
|
"in-domain-delete": {"templates/in_domain_delete.html"},
|
|
"in-log": {"templates/in_log.html"},
|
|
"in-filter-lists": {"templates/in_filter_lists.html"},
|
|
"out-log": {"templates/out_log.html", "templates/out_log_rows.html"},
|
|
"out-message": {"templates/out_message.html"},
|
|
"dmarc": {"templates/dmarc.html"},
|
|
"dmarc-domain": {"templates/dmarc_domain.html"},
|
|
"dmarc-report": {"templates/dmarc_report.html"},
|
|
"out-queue": {"templates/out_queue.html", "templates/out_queue_body.html"},
|
|
"components": {"templates/kit.html"},
|
|
}
|
|
|
|
// fragmentFiles maps a fragment name (also its {{define}} block name) to its
|
|
// template file, for standalone rendering by the HTMX polling endpoints.
|
|
var fragmentFiles = map[string]string{
|
|
"out_log_rows": "templates/out_log_rows.html",
|
|
"out_queue_body": "templates/out_queue_body.html",
|
|
"system_log_body": "templates/system_log_body.html",
|
|
"health_body": "templates/health_body.html",
|
|
"overview_poll": "templates/overview_cards.html",
|
|
}
|
|
|
|
// New parses embedded templates. version is stamped into every page footer.
|
|
func New(version string) (*Engine, error) {
|
|
e := &Engine{
|
|
pages: make(map[string]*template.Template),
|
|
fragments: make(map[string]*template.Template),
|
|
version: version,
|
|
}
|
|
for name, files := range pageFiles {
|
|
// A page parses with the layout and the kit's partials. The template
|
|
// is named after the layout file so that its content is the layout's.
|
|
const layout = "layout.html"
|
|
patterns := append([]string{"templates/" + layout, "templates/components.html"}, files...)
|
|
tmpl, err := template.New(layout).Funcs(templateFuncs()).ParseFS(assetsFS, patterns...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse template %s: %w", name, err)
|
|
}
|
|
e.pages[name] = tmpl
|
|
}
|
|
for name, file := range fragmentFiles {
|
|
// A fragment calls the kit's partials, so it parses with them.
|
|
patterns := []string{"templates/components.html", file}
|
|
tmpl, err := template.New(path.Base(file)).Funcs(templateFuncs()).ParseFS(assetsFS, patterns...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse fragment %s: %w", name, err)
|
|
}
|
|
e.fragments[name] = tmpl
|
|
}
|
|
return e, nil
|
|
}
|
|
|
|
// SetInboundEnabled controls whether the Inbound nav item is shown. The
|
|
// listener and routes are gated the same way (INBOUND_RELAY_ENABLE).
|
|
func (e *Engine) SetInboundEnabled(v bool) {
|
|
e.inboundEnabled = v
|
|
}
|
|
|
|
// SetDMARCEnabled controls whether the DMARC nav item is shown.
|
|
func (e *Engine) SetDMARCEnabled(v bool) {
|
|
e.dmarcEnabled = v
|
|
}
|
|
|
|
// templateFuncs supplies helpers shared across page templates.
|
|
func templateFuncs() template.FuncMap {
|
|
return template.FuncMap{
|
|
// status_tag and wbr_at are the adapters of the component kit
|
|
// (components.go): the one mapping of a status to Bulma's tag
|
|
// classes, and the break opportunity after the @ of an address.
|
|
"status_tag": StatusTag,
|
|
"wbr_at": wbrAt,
|
|
}
|
|
}
|
|
|
|
// Page returns a parsed page template by logical name. It is exported for
|
|
// template guard tests that assert structural properties across all pages.
|
|
func (e *Engine) Page(name string) *template.Template {
|
|
return e.pages[name]
|
|
}
|
|
|
|
// Pages returns all parsed page templates keyed by logical name.
|
|
func (e *Engine) Pages() map[string]*template.Template {
|
|
return e.pages
|
|
}
|
|
|
|
// Render writes a page using its layout. Rendering to a buffer first means a
|
|
// template error yields a clean 500 instead of a half-written page.
|
|
func (e *Engine) Render(w http.ResponseWriter, status int, page string, data any) {
|
|
tmpl, ok := e.pages[page]
|
|
if !ok {
|
|
http.Error(w, "template not found", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// The shell is typed data derived from the page's Meta (components.go), and
|
|
// the page's own data travels beside it. With no user there is no
|
|
// navigation — the signed-out screen.
|
|
var buf bytes.Buffer
|
|
layout := "layout.html"
|
|
sh := e.shell(metaOf(data), legal.CopyrightLine, legal.SourceURL)
|
|
if sh.User == "" {
|
|
layout = "layout_signed_out"
|
|
}
|
|
frame := Frame{Shell: sh, Page: data}
|
|
if err := tmpl.ExecuteTemplate(&buf, layout, frame); err != nil {
|
|
log.Printf("panel: render %s: %v", page, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_, _ = buf.WriteTo(w)
|
|
}
|
|
|
|
// RenderFragment writes an HTMX polling fragment as a bare HTML snippet, with
|
|
// no surrounding layout (architecture.md § Panel HTTP surface: fragment
|
|
// endpoints return HTML, not JSON).
|
|
func (e *Engine) RenderFragment(w http.ResponseWriter, status int, name string, data any) {
|
|
tmpl, ok := e.fragments[name]
|
|
if !ok {
|
|
http.Error(w, "template not found", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
|
|
log.Printf("panel: render fragment %s: %v", name, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_, _ = buf.WriteTo(w)
|
|
}
|