// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Package admin serves the server-rendered admin UI: authentication, // post management, settings, and the media library. package admin import ( "bytes" "context" "encoding/json/v2" "fmt" "html/template" "io" "strings" "sync" "sourcedock.dev/petrbalvin/volumen/internal/config" "sourcedock.dev/petrbalvin/volumen/internal/diff" "sourcedock.dev/petrbalvin/volumen/internal/i18n" "sourcedock.dev/petrbalvin/volumen/internal/users" "sourcedock.dev/petrbalvin/volumen/internal/web" ) // Crumb is one breadcrumb entry. UI marks a fixed interface label the // renderer translates; content labels (post titles) pass through. type Crumb struct { Label string Href string IsLast bool UI bool } // PageData is the template context shared by every admin page; page // handlers fill the specific fields they need. type PageData struct { Config *config.Config Path string CSPNonce string Version string UpdateAvailable string // Lang is the interface language this request renders in ("en" or // "cs"), resolved from the account, the language cookie, or the // site language. It feeds the html lang attribute, which the date // picker and the relative-time formatter read. Lang string // Theme is the colour scheme this request renders in, resolved // from the account, the theme cookie, or the default. It feeds the // html data-theme attribute the stylesheet's scheme blocks read. Theme string CurrentUser string CurrentRole string CurrentUserRecord *users.User UsersExist bool CSRFToken string IsLogin bool IsSetup bool IsAuthenticated bool // SetupI18n carries the wizard's strings in every shipped // language, so the language chips can swap the page text without // a reload and without losing what the operator typed. SetupI18n template.JS DisplayName string UserPhoto string UserInitial string Crumbs []Crumb Error string RetryAfter int Notice string // Dashboard. Posts []postCard Stats dashboardStats TagCounts []tagCount Q string // Editor and history. IsNew bool IsEdit bool Post *editorPost PostTemplates []tplOption TemplatesJSON template.JS Restored bool Duplicated bool AuthorPlaceholder string PreviewToken string Slug string Heading string Revisions []revisionRow DiffChunks []diff.Chunk DiffName string DiffWhen string // Settings. IsAdmin bool Roles []string DefaultRole string UserRows []userRow TemplatesList []tplOption WebhookRows []hookRow WebhookDeliveries []deliveryRow TokenRows []tokenRow NewToken string MediaItems []mediaRow // The second factor: its state on the account, an enrolment in // flight, and the one-time recovery codes a change just produced. TotpEnabled bool TotpPending bool TotpSVG template.HTML TotpSecret string TotpURI string RecoveryCodes []string RecoveryNotice string MediaTotal string Target string // Sidebar navigation highlighting. NavPosts bool NavNew bool NavImport bool NavMedia bool NavSettings bool } // Tr translates a simple message in this request's language. Handlers // use it for the strings they compose in Go; templates use the tr and // trn funcs, which read the same catalogue. func (d *PageData) Tr(s string) string { return i18n.Admin.T(d.Lang, s) } // Trf translates a simple message with one value. func (d *PageData) Trf(s, arg string) string { return i18n.Admin.Tf(d.Lang, s, arg) } // langRenderer is one language's parsed template set. The translation // funcs close over the language, so a page renders whole in one tongue // with no per-string lookups in the handlers. type langRenderer struct { pages map[string]*template.Template } // Renderer executes the embedded admin templates in every shipped // language. type Renderer struct { mu sync.Mutex langs map[string]*langRenderer } // NewRenderer parses the layout together with every page template, one // set per shipped language. func NewRenderer() (*Renderer, error) { fs := web.TemplateFS() r := &Renderer{langs: map[string]*langRenderer{}} for _, lang := range i18n.Languages { base, err := template.New("layout.html").Funcs(funcMap(lang)).ParseFS(fs, "templates/layout.html") if err != nil { return nil, fmt.Errorf("parse layout (%s): %w", lang, err) } lr := &langRenderer{pages: map[string]*template.Template{}} for _, page := range pageNames() { clone, err := base.Clone() if err != nil { return nil, fmt.Errorf("clone layout for %s (%s): %w", page, lang, err) } if _, err := clone.ParseFS(fs, "templates/"+page); err != nil { return nil, fmt.Errorf("parse %s (%s): %w", page, lang, err) } lr.pages[page] = clone } r.langs[lang] = lr } return r, nil } // pageNames lists the page templates parsed alongside the layout. func pageNames() []string { return []string{ "login.html", "setup.html", "twofactor.html", "list.html", "form.html", "history.html", "diff.html", "import.html", "settings.html", "media.html", "update.html", "notfound.html", } } // Render executes the named page inside the layout shell, in the // language the page data carries. The context is the request's, so a // template failure is logged against it. func (r *Renderer) Render(ctx context.Context, w io.Writer, page string, data *PageData) error { lang := data.Lang if !i18n.Valid(lang) { lang = "en" } // Fixed breadcrumb labels are interface strings; content labels // (a post title) pass through untouched. for i, c := range data.Crumbs { if c.UI { data.Crumbs[i].Label = i18n.Admin.T(lang, c.Label) } } r.mu.Lock() lr := r.langs[lang] r.mu.Unlock() tmpl, ok := lr.pages[page] if !ok { return fmt.Errorf("unknown admin page %q", page) } var buf bytes.Buffer if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil { web.Logger(ctx).Warn("admin: template error", "page", page, "error", err) return err } _, err := w.Write(buf.Bytes()) return err } func funcMap(lang string) template.FuncMap { cat := i18n.Admin return template.FuncMap{ "lower": strings.ToLower, "join": func(items []string, sep string) string { return strings.Join(items, sep) }, "tr": func(s string) string { return cat.T(lang, s) }, "trh": func(s string) template.HTML { return template.HTML(cat.TH(lang, s)) }, "trf": func(s, arg string) string { return cat.Tf(lang, s, arg) }, "trn": func(n int, id string) string { return cat.N(lang, id, n) }, "i18nJSON": func() template.JS { b, err := json.Marshal(cat.JS(lang)) if err != nil { return "{}" } // The catalogue holds authored strings only, but the same // script-embedding rule as templatesJSON applies: no literal // "<" may reach the page inside a script element. return template.JS(strings.ReplaceAll(string(b), "<", `\u003c`)) }, } }