// Copyright (c) 2026 Petr Balvín (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": "", "message": "", …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 "". 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) }