first commit
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
// M is the per-page template data map — mirrors the kwargs Flask's render_template(...)
|
||||
// is called with. Using a map (not per-page structs) keeps 18 template contexts from
|
||||
// needing 18 Go struct types.
|
||||
type M map[string]any
|
||||
|
||||
func (a *App) funcMap() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"formatDatetime": func(t time.Time) string { return formatDatetimeInZone(t, a.Cfg) },
|
||||
"strftime": func(layout string, t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format(pyToGoLayout(layout))
|
||||
},
|
||||
"title": strings.Title,
|
||||
"upper": strings.ToUpper,
|
||||
"lower": strings.ToLower,
|
||||
"safe": func(s string) template.HTML { return template.HTML(s) },
|
||||
"filesize": humanFileSize,
|
||||
"dotToDash": func(s string) string { return strings.ReplaceAll(s, ".", "-") },
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"eq2": func(a, b any) bool { return fmt.Sprint(a) == fmt.Sprint(b) },
|
||||
// dget looks up an optional map key, returning "" if absent — mirrors Jinja's
|
||||
// `x if x is defined else ''` pattern used for context vars only some pages set
|
||||
// (e.g. sidebar badge counts, which only dashboard passes).
|
||||
"dget": func(m M, key string) any {
|
||||
if v, ok := m[key]; ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
},
|
||||
"list": func(items ...string) []string { return items },
|
||||
// emailOverallStatus mirrors the delivered/failed selectattr computation
|
||||
// dashboard.html and logs.html both do in the Python templates.
|
||||
"emailOverallStatus": func(recipients []db.EmailRecipientLog) string {
|
||||
delivered, failed := 0, 0
|
||||
for _, r := range recipients {
|
||||
if r.Status == "success" {
|
||||
delivered++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case delivered > 0 && failed > 0:
|
||||
return "partial"
|
||||
case delivered > 0:
|
||||
return "relayed"
|
||||
default:
|
||||
return "failed"
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// pyToGoLayout converts the handful of Python strftime directives this app actually
|
||||
// uses into Go's reference-time layout.
|
||||
func pyToGoLayout(py string) string {
|
||||
r := strings.NewReplacer(
|
||||
"%Y", "2006", "%m", "01", "%d", "02",
|
||||
"%H", "15", "%M", "04", "%S", "05",
|
||||
)
|
||||
return r.Replace(py)
|
||||
}
|
||||
|
||||
func formatDatetimeInZone(t time.Time, cfg *ini.File) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
tzName := cfg.Section("Server").Key("time_zone").MustString("UTC")
|
||||
loc, err := time.LoadLocation(tzName)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
return t.In(loc).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
func humanFileSize(size int64) string {
|
||||
const unit = 1024
|
||||
if size < unit {
|
||||
return fmt.Sprintf("%d B", size)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := size / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// pages lists every template that extends base.html — each gets its own isolated
|
||||
// template set (base.html + sidebar_email.html + itself) so their same-named
|
||||
// {{define "content"}} blocks don't collide with each other (see loadTemplates).
|
||||
var pages = []string{
|
||||
"dashboard.html", "domains.html", "add_domain.html", "edit_domain.html",
|
||||
"senders.html", "add_sender.html", "edit_sender.html",
|
||||
"ips.html", "add_ip.html", "edit_ip.html",
|
||||
"dkim.html", "edit_dkim.html",
|
||||
"settings.html", "logs.html", "view_message_content.html", "error.html",
|
||||
"account.html", "first_login.html", "totp_setup.html",
|
||||
"admins.html", "add_admin.html", "edit_admin.html",
|
||||
}
|
||||
|
||||
// standalonePages are pre-login screens — they intentionally don't use base.html's
|
||||
// sidebar/dashboard chrome, since the visitor isn't authenticated yet.
|
||||
var standalonePages = []string{"login.html", "login_mfa.html"}
|
||||
|
||||
// loadTemplates parses from the embedded assets FS (see embed.go), not the
|
||||
// filesystem — the binary carries its own templates, so it runs from any working
|
||||
// directory without needing the source tree alongside it.
|
||||
func (a *App) loadTemplates() error {
|
||||
a.templates = map[string]*template.Template{}
|
||||
for _, page := range pages {
|
||||
t := template.New("base.html").Funcs(a.funcMap())
|
||||
t, err := t.ParseFS(assets, "templates/base.html", "templates/sidebar_email.html", "templates/"+page)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse %s: %w", page, err)
|
||||
}
|
||||
a.templates[page] = t
|
||||
}
|
||||
for _, page := range standalonePages {
|
||||
t := template.New(page).Funcs(a.funcMap())
|
||||
t, err := t.ParseFS(assets, "templates/"+page)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse %s: %w", page, err)
|
||||
}
|
||||
a.templates[page] = t
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isStandalonePage(page string) bool {
|
||||
for _, p := range standalonePages {
|
||||
if p == page {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// render executes the named page template — as itself for standalone (pre-login)
|
||||
// pages, or as "base.html" for everything else — mirroring flask.render_template.
|
||||
func (a *App) render(w http.ResponseWriter, r *http.Request, page string, data M) {
|
||||
t, ok := a.templates[page]
|
||||
if !ok {
|
||||
http.Error(w, "template not found: "+page, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if data == nil {
|
||||
data = M{}
|
||||
}
|
||||
if isStandalonePage(page) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.ExecuteTemplate(w, page, data); err != nil {
|
||||
a.Logger.Error("template render error (%s): %v", page, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
data["flashes"] = popFlashes(w, r)
|
||||
data["health"] = a.checkHealth()
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.ExecuteTemplate(w, "base.html", data); err != nil {
|
||||
a.Logger.Error("template render error (%s): %v", page, err)
|
||||
}
|
||||
}
|
||||
|
||||
func atoi(s string) int {
|
||||
n, _ := strconv.Atoi(s)
|
||||
return n
|
||||
}
|
||||
Reference in New Issue
Block a user