Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s

Assisted-by: GLM 5.3
This commit is contained in:
2026-09-29 10:03:32 +02:00
commit f8ed33df83
206 changed files with 44165 additions and 0 deletions
+244
View File
@@ -0,0 +1,244 @@
// 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`))
},
}
}