Files
volumen/internal/httpapi/api.go
T

938 lines
30 KiB
Go
Raw Normal View History

2026-09-18 12:03:35 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package httpapi serves the public JSON API under /api/volumen:
// site metadata, paginated posts, tags, series, feeds, the sitemap,
// and token-authenticated write endpoints.
package httpapi
import (
"crypto/sha256"
"encoding/hex"
json "encoding/json/v2"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"sync"
"time"
"sourcedock.dev/petrbalvin/interpres/v2"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/feeds"
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
"sourcedock.dev/petrbalvin/volumen/internal/post"
"sourcedock.dev/petrbalvin/volumen/internal/preview"
"sourcedock.dev/petrbalvin/volumen/internal/tokens"
"sourcedock.dev/petrbalvin/volumen/internal/web"
)
// Content is the content surface the API uses.
type Content interface {
All() []*post.Post
Find(slug, lang string) *post.Post
ResolveAlias(alias string) string
Save(p *post.Post) (*post.Post, error)
Delete(slug, lang string) (*post.Post, bool, error)
CacheKey() string
}
// Deps are the shared services the API needs.
type Deps struct {
Config *config.Config
Store Content
Tokens *tokens.Store
OnEvent func(event string, payload map[string]any)
// PreviewKey verifies the shareable preview links: the session
// secret the app layer resolved, from [admin].session_key or from
// the secret.key file it generated, which is the same key the admin
// signs the links with. Empty refuses every token.
PreviewKey string
}
// API routes /api/volumen requests.
type API struct {
deps Deps
sitemapMu sync.Mutex
sitemapKey string
sitemapXML string
}
// New builds the API handler.
func New(deps Deps) http.Handler {
api := &API{deps: deps}
mux := http.NewServeMux()
mux.HandleFunc("OPTIONS /api/volumen/{rest...}", api.handleOptions)
mux.HandleFunc("GET /api/volumen/site", api.handleSite)
mux.HandleFunc("GET /api/volumen/posts", api.handlePosts)
mux.HandleFunc("GET /api/volumen/posts/batch", api.handleBatch)
mux.HandleFunc("GET /api/volumen/posts/{slug}", api.handleSingle)
mux.HandleFunc("POST /api/volumen/posts", api.handleCreatePost)
mux.HandleFunc("PUT /api/volumen/posts/{slug}", api.handleUpdatePost)
mux.HandleFunc("DELETE /api/volumen/posts/{slug}", api.handleDeletePost)
mux.HandleFunc("GET /api/volumen/tags", api.handleTags)
mux.HandleFunc("GET /api/volumen/tags/{tag}", api.handleTagPosts)
mux.HandleFunc("GET /api/volumen/tags/{tag}/feed.xml", api.handleTagRSS)
mux.HandleFunc("GET /api/volumen/tags/{tag}/feed.atom", api.handleTagAtom)
mux.HandleFunc("GET /api/volumen/tags/{tag}/feed.json", api.handleTagJSON)
mux.HandleFunc("GET /api/volumen/series", api.handleSeries)
mux.HandleFunc("GET /api/volumen/series/{name}", api.handleSeriesDetail)
mux.HandleFunc("GET /api/volumen/series/{name}/feed.xml", api.handleSeriesRSS)
mux.HandleFunc("GET /api/volumen/series/{name}/feed.atom", api.handleSeriesAtom)
mux.HandleFunc("GET /api/volumen/series/{name}/feed.json", api.handleSeriesJSON)
mux.HandleFunc("GET /api/volumen/feed.xml", api.handleRSS)
mux.HandleFunc("GET /api/volumen/feed.atom", api.handleAtom)
mux.HandleFunc("GET /api/volumen/feed.json", api.handleJSONFeed)
mux.HandleFunc("GET /api/volumen/sitemap.xml", api.handleSitemap)
return mux
}
var corsHeaders = map[string]string{
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
}
const cacheHeader = "public, max-age=60, stale-while-revalidate=21600"
// pageSizeLimit bounds the page size: the documented limit of the list
// endpoints, used both by the clamp and by the error message so the two
// cannot drift.
const pageSizeLimit = 100
// maxWriteBody bounds a write payload.
const maxWriteBody = 10 << 20
// maxPage bounds the page number so offset arithmetic stays far inside
// 32-bit int range.
const maxPage = 1_000_000
func (a *API) fire(event string, payload map[string]any) {
if a.deps.OnEvent != nil {
a.deps.OnEvent(event, payload)
}
}
func writeCORS(w http.ResponseWriter) {
for key, value := range corsHeaders {
w.Header().Set(key, value)
}
w.Header().Set("Cache-Control", cacheHeader)
}
// writeJSON writes a JSON response with CORS and cache headers.
func writeJSON(w http.ResponseWriter, r *http.Request, status int, v any) {
writeJSONWithHeaders(w, r, status, v, nil)
}
// writeJSONWithHeaders applies extra headers last, so write endpoints
// can override the public CORS defaults.
func writeJSONWithHeaders(w http.ResponseWriter, r *http.Request, status int, v any, extra map[string]string) {
writeCORS(w)
for key, value := range extra {
w.Header().Set(key, value)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
writeJSONBody(w, r, v, "cannot encode response")
}
// writeJSONBody writes one JSON document and the newline a line-oriented
// client expects. encoding/json/v2 escapes only what JSON requires, so a
// body keeps the characters the author wrote.
func writeJSONBody(w io.Writer, r *http.Request, v any, what string) {
if err := json.MarshalWrite(w, v, json.Deterministic(true)); err != nil {
web.Logger(r.Context()).Warn("httpapi: "+what, "error", err)
return
}
if _, err := io.WriteString(w, "\n"); err != nil {
web.Logger(r.Context()).Warn("httpapi: "+what, "error", err)
}
}
// writeError writes the uniform error envelope:
//
// {"error": "<code>", "message": "<human text>", …extras}
//
// Every failure, in the API and in the middleware, uses this shape with
// CORS headers so cross-origin clients can read it. An error is never
// publicly cacheable.
func writeError(w http.ResponseWriter, r *http.Request, status int, body map[string]any) {
writeCORS(w)
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
writeJSONBody(w, r, body, "cannot encode error")
}
func writeXML(w http.ResponseWriter, r *http.Request, contentType, body string) {
writeCORS(w)
w.Header().Set("Content-Type", contentType)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(body))
}
// etagFor hashes the canonical serialisation of a payload, so two equal
// payloads always produce one tag: without Deterministic a map inside the
// payload could serialise in a different order on the next request and
// change the tag for the same content.
func etagFor(v any) string {
raw, err := json.Marshal(v, json.Deterministic(true))
if err != nil {
return `""`
}
sum := sha256.Sum256(raw)
return `"` + hex.EncodeToString(sum[:8]) + `"`
}
func etagMatches(header, etag string) bool {
opaque := func(tag string) string {
tag = strings.TrimSpace(tag)
tag = strings.TrimPrefix(tag, "W/")
return strings.Trim(tag, `"`)
}
stored := opaque(etag)
for tag := range strings.SplitSeq(header, ",") {
if strings.TrimSpace(tag) == "*" || opaque(tag) == stored {
return true
}
}
return false
}
func maybeNotModified(w http.ResponseWriter, r *http.Request, etag string) bool {
inm := r.Header.Get("If-None-Match")
if inm == "" || !etagMatches(inm, etag) {
return false
}
writeCORS(w)
w.Header().Set("ETag", etag)
w.WriteHeader(http.StatusNotModified)
return true
}
func writeJSONWithETag(w http.ResponseWriter, r *http.Request, v any) {
etag := etagFor(v)
if maybeNotModified(w, r, etag) {
return
}
w.Header().Set("ETag", etag)
writeJSON(w, r, http.StatusOK, v)
}
func (a *API) baseURL() string {
return strings.TrimRight(a.deps.Config.Site.BaseURL, "/")
}
func (a *API) handleOptions(w http.ResponseWriter, r *http.Request) {
// The preflight answers for the whole subtree, so it advertises the
// write methods as well; a browser preflight for a cross-origin POST
// fails when the response lists only GET.
for key, value := range writeCORSHeaders(r, a.deps.Config) {
w.Header().Set(key, value)
}
w.Header().Set("Access-Control-Max-Age", "600")
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
}
func (a *API) handleSite(w http.ResponseWriter, r *http.Request) {
writeJSONWithETag(w, r, payloads.BuildSite(a.deps.Config))
}
func queryInt(r *http.Request, name string, def, lo, hi int) (int, bool) {
raw := r.URL.Query().Get(name)
if raw == "" {
return def, true
}
n, err := strconv.Atoi(raw)
if err != nil {
return 0, false
}
if n < lo || n > hi {
return 0, false
}
return n, true
}
func writeQueryValidationError(w http.ResponseWriter, r *http.Request, name, msg string) {
writeError(w, r, http.StatusUnprocessableEntity, map[string]any{
"error": "validation",
"message": msg,
"field": name,
})
}
func (a *API) handlePosts(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
page, ok := queryInt(r, "page", 1, 1, maxPage)
if !ok {
writeQueryValidationError(w, r, "page", "Input should be between 1 and "+strconv.Itoa(maxPage))
return
}
limit, ok := queryInt(r, "limit", 20, 1, pageSizeLimit)
if !ok {
writeQueryValidationError(w, r, "limit", "Input should be between 1 and "+strconv.Itoa(pageSizeLimit))
return
}
payload := payloads.PostsPayload(
a.deps.Store,
q.Get("lang"), q.Get("tag"), q.Get("q"),
page, limit, q.Get("cursor"),
)
writeJSONWithETag(w, r, payload)
}
func (a *API) handleBatch(w http.ResponseWriter, r *http.Request) {
slugsParam := r.URL.Query().Get("slugs")
if slugsParam == "" {
writeJSON(w, r, http.StatusOK, map[string]any{"posts": []any{}})
return
}
var slugs []string
for slug := range strings.SplitSeq(slugsParam, ",") {
if trimmed := strings.TrimSpace(slug); trimmed != "" {
slugs = append(slugs, trimmed)
}
}
if len(slugs) > pageSizeLimit {
slugs = slugs[:pageSizeLimit]
}
// One listing serves the whole batch: Find re-walks the content
// directory per call, so a hundred slugs would walk it a hundred
// times. The map keeps Find(slug, "") semantics: the first post in
// listing order that carries the slug.
posts := a.deps.Store.All()
first := make(map[string]*post.Post, len(posts))
for _, p := range posts {
if _, ok := first[p.Slug()]; !ok {
first[p.Slug()] = p
}
}
base := a.baseURL()
results := make([]payloads.Detail, 0, len(slugs))
for _, slug := range slugs {
p := first[slug]
if p == nil || !p.Published() {
continue
}
detail, err := payloads.BuildDetail(p, base)
if err != nil {
web.Logger(r.Context()).Warn("httpapi: cannot render post", "slug", slug, "error", err)
continue
}
results = append(results, detail)
}
etag := etagFor(results)
if maybeNotModified(w, r, etag) {
return
}
w.Header().Set("ETag", etag)
writeJSON(w, r, http.StatusOK, payloads.Batch{Posts: results})
}
func (a *API) validPreviewToken(token, slug string) bool {
return preview.Valid(token, slug, a.deps.PreviewKey, time.Now())
}
func (a *API) handleSingle(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
lang := r.URL.Query().Get("lang")
p := a.deps.Store.Find(slug, lang)
if p == nil {
if canonical := a.deps.Store.ResolveAlias(slug); canonical != "" {
w.Header().Set("Location", "/api/volumen/posts/"+canonical)
w.WriteHeader(http.StatusMovedPermanently)
return
}
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
if !p.Published() && !a.validPreviewToken(r.URL.Query().Get("preview_token"), slug) {
// A draft and a scheduled post are distinguishable on purpose:
// the client knows the slug already, and the two states need
// different handling on the other side.
if p.Draft() {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "draft"})
return
}
writeError(w, r, http.StatusNotFound, map[string]any{"error": "scheduled"})
return
}
detail, err := payloads.BuildDetail(p, a.baseURL())
if err != nil {
writeError(w, r, http.StatusInternalServerError, map[string]any{"error": "render_failed"})
return
}
if !p.Published() {
// The URL is only valid with the preview token, but a shared cache
// would still be allowed to hold the unpublished content for the
// header's lifetime; a draft or a scheduled post is not
// publicly cacheable.
writeJSONWithHeaders(w, r, http.StatusOK, detail,
map[string]string{"Cache-Control": "no-store"})
return
}
writeJSONWithETag(w, r, detail)
}
func (a *API) handleTags(w http.ResponseWriter, r *http.Request) {
writeJSONWithETag(w, r, payloads.TagList{Tags: payloads.BuildTagCounts(a.publishedPosts())})
}
func (a *API) handleTagPosts(w http.ResponseWriter, r *http.Request) {
tag := r.PathValue("tag")
q := r.URL.Query()
page, ok := queryInt(r, "page", 1, 1, maxPage)
if !ok {
writeQueryValidationError(w, r, "page", "Input should be between 1 and "+strconv.Itoa(maxPage))
return
}
limit, ok := queryInt(r, "limit", 20, 1, 100)
if !ok {
writeQueryValidationError(w, r, "limit", "Input should be between 1 and "+strconv.Itoa(pageSizeLimit))
return
}
payload := payloads.PostsPayload(a.deps.Store, q.Get("lang"), tag, "", page, limit, q.Get("cursor"))
if payloads.IsEmpty(payload) {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
writeJSONWithETag(w, r, payload)
}
func (a *API) handleSeries(w http.ResponseWriter, r *http.Request) {
writeJSON(w, r, http.StatusOK, payloads.SeriesList{Series: payloads.BuildSeriesList(a.deps.Store)})
}
func (a *API) handleSeriesDetail(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
posts := payloads.SeriesPosts(a.deps.Store, name)
if len(posts) == 0 {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
summaries := make([]payloads.Summary, 0, len(posts))
for _, p := range posts {
summaries = append(summaries, payloads.BuildSummary(p))
}
writeJSON(w, r, http.StatusOK, payloads.SeriesDetail{
Name: name,
Count: len(posts),
Posts: summaries,
})
}
func (a *API) publishedPosts() []*post.Post {
return payloads.PublishedPosts(a.deps.Store)
}
func (a *API) postsWithTag(tag string) []*post.Post {
var out []*post.Post
for _, p := range a.publishedPosts() {
if slices.Contains(p.Tags(), tag) {
out = append(out, p)
}
}
return out
}
func (a *API) handleTagRSS(w http.ResponseWriter, r *http.Request) {
tag := r.PathValue("tag")
posts := a.postsWithTag(tag)
if len(posts) == 0 {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
writeXML(w, r, "application/rss+xml",
feeds.RenderRSSFeed(posts, a.deps.Config.Site, a.baseURL(), tagFeedPath(tag, "xml")))
}
func (a *API) handleTagAtom(w http.ResponseWriter, r *http.Request) {
tag := r.PathValue("tag")
posts := a.postsWithTag(tag)
if len(posts) == 0 {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
writeXML(w, r, "application/atom+xml",
feeds.RenderAtomFeed(posts, a.deps.Config.Site, a.baseURL(), tagFeedPath(tag, "atom")))
}
func (a *API) handleTagJSON(w http.ResponseWriter, r *http.Request) {
tag := r.PathValue("tag")
posts := a.postsWithTag(tag)
if len(posts) == 0 {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
a.writeJSONFeed(w, r, posts, tagFeedPath(tag, "json"))
}
func (a *API) handleSeriesRSS(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
posts := payloads.SeriesPosts(a.deps.Store, name)
if len(posts) == 0 {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
writeXML(w, r, "application/rss+xml",
feeds.RenderRSSFeed(posts, a.deps.Config.Site, a.baseURL(), seriesFeedPath(name, "xml")))
}
func (a *API) handleSeriesAtom(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
posts := payloads.SeriesPosts(a.deps.Store, name)
if len(posts) == 0 {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
writeXML(w, r, "application/atom+xml",
feeds.RenderAtomFeed(posts, a.deps.Config.Site, a.baseURL(), seriesFeedPath(name, "atom")))
}
func (a *API) handleSeriesJSON(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
posts := payloads.SeriesPosts(a.deps.Store, name)
if len(posts) == 0 {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
a.writeJSONFeed(w, r, posts, seriesFeedPath(name, "json"))
}
func (a *API) handleRSS(w http.ResponseWriter, r *http.Request) {
writeXML(w, r, "application/rss+xml",
feeds.RenderRSSFeed(a.publishedPosts(), a.deps.Config.Site, a.baseURL(), siteFeedPath("xml")))
}
func (a *API) handleAtom(w http.ResponseWriter, r *http.Request) {
writeXML(w, r, "application/atom+xml",
feeds.RenderAtomFeed(a.publishedPosts(), a.deps.Config.Site, a.baseURL(), siteFeedPath("atom")))
}
func (a *API) handleJSONFeed(w http.ResponseWriter, r *http.Request) {
a.writeJSONFeed(w, r, a.publishedPosts(), siteFeedPath("json"))
}
// Feed paths, used for the self link and feed_url of each document.
func siteFeedPath(format string) string { return "/api/volumen/feed." + format }
func tagFeedPath(tag, format string) string {
return "/api/volumen/tags/" + url.PathEscape(tag) + "/feed." + format
}
func seriesFeedPath(name, format string) string {
return "/api/volumen/series/" + url.PathEscape(name) + "/feed." + format
}
func (a *API) writeJSONFeed(w http.ResponseWriter, r *http.Request, posts []*post.Post, selfPath string) {
body, err := feeds.MarshalJSONFeed(feeds.RenderJSONFeed(posts, a.deps.Config.Site, a.baseURL(), selfPath))
if err != nil {
writeError(w, r, http.StatusInternalServerError, map[string]any{"error": "render_failed"})
return
}
writeCORS(w)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(append(body, '\n'))
}
func (a *API) handleSitemap(w http.ResponseWriter, r *http.Request) {
// The key is the store's snapshot, which changes whenever a post file
// is added, edited, touched or removed: the sitemap's lastmod comes
// from the file's modification time, so keying on the path and date
// alone would serve a stale lastmod for the life of the process.
// The key is taken before the posts: content changing between the two
// reads would pin XML rendered from the older snapshot under the newer
// key, and the cache would serve it until the next change. Key-first,
// the worst case is XML newer than its key, which recomputes.
key := a.deps.Store.CacheKey()
posts := a.publishedPosts()
a.sitemapMu.Lock()
if a.sitemapXML != "" && a.sitemapKey == key {
xml := a.sitemapXML
a.sitemapMu.Unlock()
writeXML(w, r, "application/xml", xml)
return
}
a.sitemapMu.Unlock()
xml := feeds.RenderSitemap(posts, a.baseURL())
a.sitemapMu.Lock()
a.sitemapKey = key
a.sitemapXML = xml
a.sitemapMu.Unlock()
writeXML(w, r, "application/xml", xml)
}
// --- token-authenticated writes ---------------------------------------------
var writeFields = []string{
"title", "slug", "lang", "author", "fediverse_creator",
"excerpt", "cover", "cover_alt", "cover_caption", "series",
}
func (a *API) requireToken(w http.ResponseWriter, r *http.Request, scope string) (*tokens.Token, bool) {
header := r.Header.Get("Authorization")
scheme, raw, found := strings.Cut(header, " ")
if !found || !strings.EqualFold(scheme, "Bearer") || strings.TrimSpace(raw) == "" {
writeUnauthorized(w, r)
return nil, false
}
token := a.deps.Tokens.Authenticate(strings.TrimSpace(raw))
if token == nil {
writeUnauthorized(w, r)
return nil, false
}
a.deps.Tokens.Touch(token.Name)
if !token.HasScope(scope) {
writeError(w, r, http.StatusForbidden, map[string]any{
"error": "forbidden",
"message": fmt.Sprintf("Token lacks '%s' scope", scope),
})
return nil, false
}
return token, true
}
// writeUnauthorized answers a missing or invalid token. The challenge
// goes out with the status line: a header set after WriteHeader never
// reaches the client.
func writeUnauthorized(w http.ResponseWriter, r *http.Request) {
writeJSONWithHeaders(w, r, http.StatusUnauthorized,
map[string]any{"error": "unauthorized"},
map[string]string{"WWW-Authenticate": "Bearer", "Cache-Control": "no-store"})
}
// writeCORSHeaders builds the restrictive CORS header set for
// token-authenticated write endpoints; only the configured base_url is
// allowed as an origin (with a wildcard fallback for setups without
// one).
func writeCORSHeaders(r *http.Request, cfg *config.Config) map[string]string {
base := strings.TrimRight(cfg.Site.BaseURL, "/")
origin := r.Header.Get("Origin")
allowed := "*"
if base != "" && origin != "" {
allowed = base
}
return map[string]string{
"Access-Control-Allow-Origin": allowed,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Cache-Control": "no-store",
}
}
// readJSONBody decodes a write payload. The decoder rejects a duplicate
// object member and invalid UTF-8, so a body that a JSON parser could
// read two ways is a 400 rather than a silent choice. A body over the
// limit is a 413, not a parse failure.
func readJSONBody(w http.ResponseWriter, r *http.Request) (map[string]any, bool) {
var data map[string]any
if err := json.UnmarshalRead(http.MaxBytesReader(w, r.Body, maxWriteBody), &data); err != nil {
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
writeError(w, r, http.StatusRequestEntityTooLarge, map[string]any{"error": "payload_too_large"})
return nil, false
}
writeError(w, r, http.StatusBadRequest, map[string]any{"error": "invalid_json"})
return nil, false
}
return data, true
}
// postFromJSON merges a JSON write payload into a post: omitted fields
// keep their values on update, an explicit null or empty string clears a
// field, and a malformed type is rejected with a validation message
// rather than coerced.
func postFromJSON(data map[string]any, existing *post.Post) (*post.Post, error) {
meta := frontmatterMetaFrom(existing)
body := ""
if existing != nil {
body = existing.Body
}
if rawBody, present := data["body"]; present {
// An explicit null clears the body, as it does every metadata
// field; keeping the stored text would contradict the merge
// contract the comment above documents.
if rawBody == nil {
body = ""
} else {
text, ok := rawBody.(string)
if !ok {
return nil, &payloads.ValidationError{Message: "body must be a string"}
}
body = text
}
}
bad := func(message string) (*post.Post, error) { return nil, &payloads.ValidationError{Message: message} }
for _, field := range writeFields {
value, present := data[field]
if !present {
continue
}
switch v := value.(type) {
case string:
if strings.TrimSpace(v) != "" {
meta.Set(field, strings.TrimSpace(v))
} else {
meta.Delete(field)
}
case nil:
meta.Delete(field)
default:
return bad(fmt.Sprintf("%s must be a string", field))
}
}
for _, dateField := range []string{"date", "publish_at"} {
value, present := data[dateField]
if !present {
continue
}
if parsed, ok := payloads.ParseDate(value); ok {
meta.Set(dateField, interpres.LocalDate{Time: parsed})
} else if value == nil || value == "" {
meta.Delete(dateField)
} else {
return bad(fmt.Sprintf("%s must be an ISO 8601 date", dateField))
}
}
if value, present := data["tags"]; present {
switch v := value.(type) {
case []any:
var cleaned []string
for _, item := range v {
// A malformed item is rejected, not coerced: fmt.Sprintf
// would turn null into the tag "<nil>".
s, ok := item.(string)
if !ok {
return bad("tags must be a list of strings")
}
if trimmed := strings.TrimSpace(s); trimmed != "" {
cleaned = append(cleaned, trimmed)
}
}
if len(cleaned) > 0 {
meta.Set("tags", cleaned)
} else {
meta.Delete("tags")
}
case string:
if strings.TrimSpace(v) != "" {
meta.Set("tags", payloads.ParseTags(v))
} else {
meta.Delete("tags")
}
case nil:
meta.Delete("tags")
default:
return bad("tags must be a list of strings")
}
}
for _, boolField := range []string{"draft", "all_langs"} {
value, present := data[boolField]
if !present {
continue
}
b, ok := value.(bool)
if !ok {
return bad(fmt.Sprintf("%s must be a boolean", boolField))
}
if b {
meta.Set(boolField, true)
} else {
meta.Delete(boolField)
}
}
if value, present := data["series_order"]; present {
switch v := value.(type) {
case nil:
meta.Delete("series_order")
case bool:
return bad("series_order must be an integer")
case float64:
if v != float64(int64(v)) {
return bad("series_order must be an integer")
}
meta.Set("series_order", int64(v))
case string:
if strings.TrimSpace(v) == "" {
meta.Delete("series_order")
break
}
n, ok := payloads.ParseInt(v)
if !ok {
return bad("series_order must be an integer")
}
meta.Set("series_order", int64(n))
default:
return bad("series_order must be an integer")
}
}
p := post.New(meta, body)
if existing != nil {
p.Path = existing.Path
}
return p, nil
}
func frontmatterMetaFrom(existing *post.Post) *frontmatter.Meta {
meta := frontmatter.NewMeta()
if existing != nil {
for _, key := range existing.Metadata.Keys() {
value, _ := existing.Metadata.Get(key)
meta.Set(key, value)
}
}
return meta
}
func (a *API) handleCreatePost(w http.ResponseWriter, r *http.Request) {
if _, ok := a.requireToken(w, r, "write"); !ok {
return
}
data, ok := readJSONBody(w, r)
if !ok {
return
}
p, err := postFromJSON(data, nil)
if err != nil {
writeError(w, r, http.StatusBadRequest, map[string]any{"error": "validation", "message": err.Error()})
return
}
if err := payloads.CreationError(p, a.deps.Store, nil); err != nil {
writeError(w, r, http.StatusBadRequest, map[string]any{"error": "validation", "message": err.Error()})
return
}
saved, err := a.deps.Store.Save(p)
if err != nil {
writeError(w, r, http.StatusInternalServerError, map[string]any{"error": "save_failed"})
return
}
summary := payloads.BuildSummary(saved)
a.fire("post.created", map[string]any{"post": summary})
headers := writeCORSHeaders(r, a.deps.Config)
// The ETag names the resource state the single-post GET serves, so a
// client can chain the create straight into an If-Match write.
if detail, err := payloads.BuildDetail(saved, a.baseURL()); err == nil {
headers["ETag"] = etagFor(detail)
}
writeJSONWithHeaders(w, r, http.StatusCreated, summary, headers)
}
// preconditionHolds checks the request's If-Match against the ETag the
// single-post GET serves for the same resource, so a client that read
// the post, edited it and writes it back fails instead of overwriting a
// change it never saw. No header is unconditional; `*` demands the post
// exists, which the caller has already established.
func (a *API) preconditionHolds(w http.ResponseWriter, r *http.Request, existing *post.Post) bool {
header := r.Header.Get("If-Match")
if header == "" {
return true
}
detail, err := payloads.BuildDetail(existing, a.baseURL())
if err != nil {
writeError(w, r, http.StatusInternalServerError, map[string]any{"error": "render_failed"})
return false
}
if etagMatches(header, etagFor(detail)) {
return true
}
writeError(w, r, http.StatusPreconditionFailed, map[string]any{"error": "precondition_failed"})
return false
}
func (a *API) handleUpdatePost(w http.ResponseWriter, r *http.Request) {
if _, ok := a.requireToken(w, r, "write"); !ok {
return
}
slug := r.PathValue("slug")
existing := a.deps.Store.Find(slug, "")
if existing == nil {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
if !a.preconditionHolds(w, r, existing) {
return
}
data, ok := readJSONBody(w, r)
if !ok {
return
}
p, err := postFromJSON(data, existing)
if err != nil {
writeError(w, r, http.StatusBadRequest, map[string]any{"error": "validation", "message": err.Error()})
return
}
if p.Slug() == "" {
p.Metadata.Set("slug", slug)
}
if err := payloads.CreationError(p, a.deps.Store, existing); err != nil {
writeError(w, r, http.StatusBadRequest, map[string]any{"error": "validation", "message": err.Error()})
return
}
// A post whose language came from its directory (not the frontmatter)
// keeps it through a rename: the payload set no lang, so the new
// default path would otherwise drop the language subdirectory and
// silently move the post into the default language.
if p.Lang() == "" {
p.SetFileLocation(existing.Slug(), existing.Lang())
}
// SavePost moves the file when the slug changed and archives the old
// one under its own language, the same way the admin editor does, so
// a rename behaves alike from either entry point.
saved, err := payloads.SavePost(a.deps.Store, p, existing)
if err != nil {
writeError(w, r, http.StatusInternalServerError, map[string]any{"error": "save_failed"})
return
}
summary := payloads.BuildSummary(saved)
a.fire("post.updated", map[string]any{"post": summary})
headers := writeCORSHeaders(r, a.deps.Config)
if detail, err := payloads.BuildDetail(saved, a.baseURL()); err == nil {
headers["ETag"] = etagFor(detail)
}
writeJSONWithHeaders(w, r, http.StatusOK, summary, headers)
}
func (a *API) handleDeletePost(w http.ResponseWriter, r *http.Request) {
if _, ok := a.requireToken(w, r, "delete"); !ok {
return
}
slug := r.PathValue("slug")
existing := a.deps.Store.Find(slug, "")
if existing == nil {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
if !a.preconditionHolds(w, r, existing) {
return
}
deleted, _, err := a.deps.Store.Delete(slug, "")
if err != nil {
web.Logger(r.Context()).Error("httpapi: cannot delete post", "slug", slug, "error", err)
writeError(w, r, http.StatusInternalServerError, map[string]any{"error": "delete_failed"})
return
}
if deleted == nil {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
a.fire("post.deleted", map[string]any{"post": map[string]any{
"slug": deleted.Slug(), "title": deleted.Title(),
}})
for key, value := range writeCORSHeaders(r, a.deps.Config) {
w.Header().Set(key, value)
}
w.WriteHeader(http.StatusNoContent)
}