245 lines
7.1 KiB
Go
245 lines
7.1 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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`))
|
||
|
|
},
|
||
|
|
}
|
||
|
|
}
|