// Copyright (c) 2026 Petr BalvĂ­n (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) }