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
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:
@@ -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`))
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user