Files
volumen/internal/admin/settings_api.go
petrbalvin f8ed33df83
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
Initial commit
Assisted-by: GLM 5.3
2026-09-29 10:03:32 +02:00

171 lines
5.5 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"fmt"
"maps"
"net/http"
"slices"
"strconv"
"strings"
"sourcedock.dev/petrbalvin/interpres/v2"
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
"sourcedock.dev/petrbalvin/volumen/internal/templates"
)
// templateFields are the editor inputs a template may pre-fill beyond
// its title, slug, tags and body; anything else in the fields box is a
// typo waiting to seed every new post with a key nobody reads.
var templateFields = map[string]bool{
"lang": true, "author": true, "fediverse_creator": true,
"doi": true, "orcid": true,
"series": true, "series_order": true,
"excerpt": true, "cover": true, "cover_alt": true, "cover_caption": true,
}
// parseTemplateFields reads the fields box: key = value lines in TOML,
// each key an editor field. unknown names the first key outside the
// allowed set; err reports text that is not a small TOML document.
func parseTemplateFields(text string) (fields map[string]string, unknown string, err error) {
if strings.TrimSpace(text) == "" {
return nil, "", nil
}
data, err := interpres.ParseMap([]byte(text))
if err != nil {
return nil, "", fmt.Errorf("template fields must be key = value lines")
}
out := map[string]string{}
for _, key := range slices.Sorted(maps.Keys(data)) {
if !templateFields[key] {
return nil, key, nil
}
value := data[key]
if value == nil {
continue
}
if text, isString := value.(string); isString {
if text == "" {
continue
}
out[key] = text
continue
}
if number, isInt := value.(int64); isInt {
out[key] = strconv.FormatInt(number, 10)
continue
}
out[key] = fmt.Sprintf("%v", value)
}
if len(out) == 0 {
return nil, "", nil
}
return out, "", nil
}
func (a *Admin) handleSettingsTemplateCreate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
name := strings.TrimSpace(r.PostFormValue("name"))
if name == "" {
a.renderSettings(w, r, a.tr(r, "Template name is required."), "", http.StatusUnprocessableEntity)
return
}
fields, unknown, err := parseTemplateFields(r.PostFormValue("fields"))
switch {
case err != nil:
a.renderSettings(w, r, a.tr(r, "Template fields must be key = value TOML lines."), "", http.StatusUnprocessableEntity)
return
case unknown != "":
a.renderSettings(w, r, a.trf(r, "Unknown template field %s.", unknown), "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Templates.Add(templates.PostTemplate{
Name: name,
Tags: payloads.ParseTags(r.PostFormValue("tags")),
Body: r.PostFormValue("body"),
Title: strings.TrimSpace(r.PostFormValue("title")),
Slug: strings.TrimSpace(r.PostFormValue("slug")),
Fields: fields,
}); err != nil {
a.renderSettings(w, r, a.trf(r, "That template could not be added: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
a.renderSettings(w, r, "", a.tr(r, "Template added."), http.StatusOK)
}
func (a *Admin) handleSettingsTemplateDelete(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
if err := a.deps.Templates.Delete(r.PathValue("name")); err != nil {
a.renderSettings(w, r, a.trf(r, "The template could not be deleted: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
a.renderSettings(w, r, "", a.tr(r, "Template deleted."), http.StatusOK)
}
// --- backup export / import -------------------------------------------------
// handleSettingsWebhookTest delivers a ping inline and reports the
// outcome from the delivery it produced, not from the shared history a
// concurrent delivery could reshuffle.
func (a *Admin) handleSettingsWebhookTest(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
if a.deps.Webhooks == nil {
a.renderSettings(w, r, a.tr(r, "No webhooks configured."), "", http.StatusUnprocessableEntity)
return
}
// The list is taken once: a settings change that reshuffles it between
// the bounds check and the fetch would test a different hook than the
// one the form named.
hooks := a.deps.Webhooks.Hooks()
index, err := strconv.Atoi(r.PathValue("index"))
if err != nil || index < 0 || index >= len(hooks) {
a.renderSettings(w, r, a.tr(r, "Webhook not found."), "", http.StatusUnprocessableEntity)
return
}
hook := hooks[index]
delivery := a.deps.Webhooks.TestHook(hook)
a.record(r, "webhook.tested", hook.URL, nil)
notice := a.tr(r, "Test delivery failed.")
if delivery.Status == "ok" {
notice = a.tr(r, "Test delivery sent.")
}
a.renderSettings(w, r, "", notice, http.StatusOK)
}
func (a *Admin) handleSettingsTokenCreate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
created, raw, err := a.deps.Tokens.Create(r.PostFormValue("name"), r.PostForm["scope"])
if err != nil {
a.renderSettings(w, r, a.trf(r, "The token could not be created: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
a.record(r, "token.created", created.Name, nil)
data := a.settingsData(r)
data.NewToken = raw
a.renderPage(w, r, "settings.html", data, http.StatusOK)
}
func (a *Admin) handleSettingsTokenDelete(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
name := r.PathValue("name")
notice := a.tr(r, "Token revoked.")
if !a.deps.Tokens.Revoke(name) {
notice = a.tr(r, "That token was not found.")
} else {
a.record(r, "token.revoked", name, nil)
}
a.renderSettings(w, r, "", notice, http.StatusOK)
}