171 lines
5.5 KiB
Go
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)
|
||
|
|
}
|