Files
fancy-dns/internal/api/api.go
T
developer 504512f247 Add a Zones console: create and manage local zones, records and delegations
The console could not see local zones at all. `blocklist.local_zones` compiled
a store that nothing read back: there was no API for a zone, its records, its
delegations or the file a record came from, and no way to change one without
editing YAML and reloading. The Domains page is the blocklist's inline rules,
not local data, so nothing in the console covered the half of the server that
answers authoritatively from disk. There is now a Zones page and the endpoints
behind it: `GET /api/zones`, `POST`/`PUT`/`DELETE /api/zones/records`,
`POST`/`DELETE /api/zones/delegations` and `DELETE /api/zones?zone=`.

**The console owns one directory and nothing else.** `blocklist.managed_zones`
holds one `<apex>.zone` per zone, with that zone's SOA, apex NS set and the
records added there, and it is compiled by the same `localzone.Load` as the
operator's files — so a record added from the console is served on the next
query, with no reload and no restart. Files in `blocklist.local_zones` are
listed and shown read-only and are never rewritten; the key defaults to empty,
so a deployment that never sets it gets a zone browser and no zone writes.
Which file a record came from is now part of the store (`zone.files`, keyed by
the index the store already uses), so the snapshot can say "console" or "your
file" per record; the query path does not read it.

**The writer proves its own output.** A record is validated by the project's own
master-file parser, so `www` means `www.<zone>` and a bare CNAME target is
completed exactly as it would be in a zone file; the file is rendered, and the
rendering is re-parsed and compared with the records it meant to write before
the file is renamed into place. A writer bug that dropped a record would
otherwise present as a zone that silently stopped answering. A zone that cannot
answer is refused rather than created: no SOA means no NXDOMAIN or NODATA
authority, and no apex NS means nothing is delegated and no server is named. The
SOA's primary follows the apex NS set — a zone whose SOA names a server outside
its own NS set hands out a referral no resolver can follow — and the serial
moves on every write.

**A delegation takes the names under it, so a record below a cut is refused.**
The store answers those names as delegated without reading the zone file, which
would make the record data that looks alive and never answers. `DELETE
/api/zones?zone=` exists for the same reason from the other side: a zone the
console created is a real zone, so it says NXDOMAIN for what it does not hold,
and deleting a record is not what gives a name back to upstream. It is
refused for a zone that also has records outside the managed directory, because
removing that file would remove none of the zone.

**Two bugs the work turned up.** `engine.applyConfig` published local-record
metrics only when `blocklist.local_zones` was non-empty, so a zone the console
created was served but never counted; the gate is now the store's own record
count. And the record-value renderer fell back to `RR.String()` for every type
it did not know, which is the whole line rather than the RDATA — it wrote a
name and a TTL into the value column for DHCID, LOC, SSHFP and SVCB, and would
have fed that back as a record value on the next edit.

Every write recompiles the store and flushes the cache. Which names changed is
not knowable from the API, and a cached upstream answer for a name the zone now
answers is simply wrong, so the flush is blunt on purpose.

The wire shapes are type aliases in `internal/api` rather than copies, so there
is one set of json tags, and `TestZoneEndpointKeySets` pins the key set at every
level with a record and a delegation actually present — an empty list only
proves the envelope. `TestZoneWritesNeedAManagedDirectory` pins the read-only
default, and `TestZoneConsoleRoundTrip` walks the whole thing: a record added
through the API is answered authoritatively on the next query, a cut stops the
parent answering below it, and deleting the zone hands the name back.

Verified against a running instance built from this tree: records and
wildcards answer with `aa`, relative names are qualified, a record below a cut
is refused with a message that says why, and the file on disk is a zone file
an operator can read. The console page is typechecked and built; the browser was
not available in this round, so it has not been looked at.
2026-09-28 07:45:16 +00:00

694 lines
23 KiB
Go

// Package api serves the admin REST API, the Prometheus metrics endpoint and
// the embedded web console.
//
// The API is the only component that mutates state, so every handler funnels
// through one of three paths: a read from a live component, a config mutation
// (validate -> write file -> swap -> broadcast), or a targeted runtime action
// (flush cache, refresh lists, reload config).
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/netip"
"os"
"strconv"
"strings"
"sync"
"time"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// Principal is the authenticated identity behind a request.
type Principal struct {
// Mode is "apikey", "basic", "oidc" or "anonymous".
Mode string
// Name is a human label for the key or user.
Name string
// ReadOnly is true for a read-only API key.
ReadOnly bool
// Loopback is true when the request came from the local machine and
// trust_loopback is on.
Loopback bool
}
// CanWrite reports whether the principal may perform mutations.
func (p Principal) CanWrite() bool { return !p.ReadOnly }
// Hooks are the live components the API reads and drives. They are interfaces
// so the api package does not depend on the engine internals.
type Hooks struct {
// Store is the config store (required).
Store *config.Store
// Logger is the structured logger (required).
Logger *slog.Logger
// Version is the running build's version.
Version string
// StartedAt is the process start time.
StartedAt time.Time
// Dashboard returns the aggregated dashboard payload.
Dashboard func(ctx context.Context, rangeKey string) (any, error)
// BlocklistState returns the compiled blocklist detail.
BlocklistState func() any
// BlocklistStats returns the compiled rule counts.
BlocklistStats func() any
// RefreshBlocklists recompiles the blocklist; empty names means all.
RefreshBlocklists func(ctx context.Context, names []string) error
// ToggleBlocklist enables or disables one source at runtime.
ToggleBlocklist func(name string, enabled bool) error
// DomainCheck explains how a name would be treated.
DomainCheck func(name string) (any, error)
// ListDomains pages the compiled rules.
ListDomains func(q DomainQuery) (any, error)
// AddDomain adds an inline rule and persists it.
AddDomain func(ctx context.Context, in DomainInput) (any, error)
// DeleteDomain removes a rule and persists the change.
DeleteDomain func(ctx context.Context, name, action, list string) error
// LocalZones lists, saves and deletes UI-managed DNS master files.
LocalZones func(ctx context.Context) ([]ZoneFile, error)
SaveLocalZone func(ctx context.Context, name, content string) error
DeleteLocalZone func(ctx context.Context, name string) error
// ListZones returns the compiled local zones with their records and
// delegations, and whether the API may rewrite them.
ListZones func() (any, error)
// AddZoneRecord adds one record to a local zone and persists it.
AddZoneRecord func(ctx context.Context, in ZoneRecordInput) (any, error)
// PutZoneRecord replaces the whole RRset of one name and type.
PutZoneRecord func(ctx context.Context, in ZoneRecordInput) (any, error)
// DeleteZoneRecord removes a record, or its whole RRset when value is
// empty, and persists the change.
DeleteZoneRecord func(ctx context.Context, zone, name, rrtype, value string) error
// DeleteZone removes a zone the console owns, so its names go back to
// whatever answered for them before.
DeleteZone func(ctx context.Context, zone string) error
// AddZoneDelegation hands a name below an apex to other servers.
AddZoneDelegation func(ctx context.Context, in ZoneDelegationInput) (any, error)
// DeleteZoneDelegation removes a cut and persists the change.
DeleteZoneDelegation func(ctx context.Context, zone, name string) error
// GetACL returns the ACL groups.
GetACL func() any
// PutACL validates and persists new ACL groups.
PutACL func(ctx context.Context, groups []config.Group, def string) (int64, error)
// GetUpstream returns the upstream pool state.
GetUpstream func() any
// PutUpstream validates and persists a new upstream config.
PutUpstream func(ctx context.Context, up config.Upstream) (int64, error)
// SearchLog pages the query log.
SearchLog func(ctx context.Context, q LogQuery) (any, error)
// LogStats is the live stats event pushed over SSE.
LogStats func() any
// SubscribeLog streams new log entries; the cancel func must be called.
SubscribeLog func() (<-chan LogEntry, func())
// ClearLog empties the query log.
ClearLog func() error
// CacheStats returns the cache snapshot.
CacheStats func() any
// CacheTop returns the hottest cached names.
CacheTop func(limit int) any
// FlushCache empties the cache.
FlushCache func() int64
// Health returns the health payload for /healthz.
Health func() any
// Status returns the per-component runtime status.
Status func() any
// Flush persists buffered state.
Flush func()
}
// DomainQuery pages the compiled rules.
type DomainQuery struct {
Text string
Action string
List string
Page int
PerPage int
}
// DomainInput adds a rule.
type DomainInput struct {
Name string `json:"name"`
Action string `json:"action"`
List string `json:"list"`
Source string `json:"source"`
Content string `json:"content,omitempty"`
}
// ZoneFile is a UI-managed authoritative master file.
type ZoneFile struct {
Name string `json:"name"`
Content string `json:"content"`
}
// LogQuery pages the query log.
type LogQuery struct {
Limit int
Offset int
Text string
Action string
Client string
RCode int
BlockedOnly bool
Since time.Time
}
// LogEntry is one query-log row as the admin API serves it.
//
// These fields are not omitempty, and that is deliberate: omitempty makes the
// key set depend on the row, so a query with no ACL group arrived without
// `client_group` and the console rendered "undefined" where the column should
// be empty. The contract test in internal/app pins the key set, and it can
// only pin one shape if the server emits one.
type LogEntry struct {
ID uint64 `json:"id"`
TS time.Time `json:"ts"`
Client string `json:"client"`
Group string `json:"client_group"`
Name string `json:"name"`
QType uint16 `json:"qtype"`
QTypeStr string `json:"qtype_name"`
RCode int `json:"rcode"`
RCodeStr string `json:"rcode_name"`
Action string `json:"action"`
Response string `json:"response"`
Duration int64 `json:"duration_ms"`
Upstream string `json:"upstream"`
List string `json:"list"`
Protocol string `json:"protocol"`
Blocked bool `json:"blocked"`
}
// Server is the admin HTTP server.
type Server struct {
hooks Hooks
log *slog.Logger
base string
mux *http.ServeMux
auth *authenticator
trusted []netip.Prefix
// sessions tracks issued session tokens for the basic-auth flow.
sessMu sync.Mutex
sessions map[string]session
// limits tracks per-IP rate limits and lockouts.
limMu sync.Mutex
limits map[string]*limitState
now func() time.Time
}
type session struct {
principal Principal
expires time.Time
}
type limitState struct {
window time.Time
count int
failures int
lockedTill time.Time
}
// New builds the API server.
func New(hooks Hooks) (*Server, error) {
if hooks.Store == nil {
return nil, errors.New("api: Store is required")
}
log := hooks.Logger
if log == nil {
log = slog.Default()
}
cfg := hooks.Store.Get()
s := &Server{
hooks: hooks,
log: log.With("component", "api"),
base: cfg.API.BasePath,
now: time.Now,
sessions: make(map[string]session),
limits: make(map[string]*limitState),
auth: newAuthenticator(cfg.API.Auth),
}
for _, c := range cfg.API.TrustedProxies {
if p, err := netip.ParsePrefix(c); err == nil {
s.trusted = append(s.trusted, p)
}
}
s.mux = s.routes(cfg)
return s, nil
}
// Handler returns the HTTP handler, wrapped with logging and panic recovery.
func (s *Server) Handler() http.Handler {
return s.recoverer(s.cors(s.mux))
}
// routes builds the route table. The stdlib mux handles the path prefix, so
// handlers see paths relative to the base.
func (s *Server) routes(cfg *config.Config) *http.ServeMux {
mux := http.NewServeMux()
// base is the prefix every route is built from, so it must end in a
// slash: "GET " + base + "healthz" has to produce "/api/healthz", not
// "/apihealthz".
base := strings.TrimSuffix(s.base, "/") + "/"
// Unauthenticated: health and metrics are for probes, not operators.
mux.Handle("GET "+base+"healthz", s.public(s.handleHealth))
if cfg.Metrics.Enabled {
// Bound late so the metrics package's init has run by the time the
// handler is invoked; a nil handler means "not wired up" (a test, or
// an embedder that does not import metrics), which must not panic the
// route table.
mux.Handle("GET "+base+"metrics", s.protectMetrics(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if h := metricsHandler(); h != nil {
h.ServeHTTP(w, r)
return
}
http.Error(w, "metrics not available", http.StatusNotImplemented)
})))
}
mux.Handle("POST "+base+"auth/login", s.limited(s.handleLogin))
mux.Handle("GET "+base+"auth/config", s.public(s.handleAuthConfig))
mux.Handle("POST "+base+"auth/logout", s.authed(s.handleLogout, false))
// Everything below requires a principal.
mux.Handle("GET "+base+"dashboard", s.authed(s.handleDashboard, false))
mux.Handle("GET "+base+"status", s.authed(s.handleStatus, false))
mux.Handle("GET "+base+"blocklists", s.authed(s.handleBlocklists, false))
mux.Handle("POST "+base+"blocklists/refresh", s.authed(s.handleBlocklistRefresh, true))
mux.Handle("POST "+base+"blocklists/toggle", s.authed(s.handleBlocklistToggle, true))
mux.Handle("GET "+base+"domains", s.authed(s.handleDomains, false))
mux.Handle("POST "+base+"domains", s.authed(s.handleDomainAdd, true))
mux.Handle("DELETE "+base+"domains/{name...}", s.authed(s.handleDomainDelete, true))
mux.Handle("GET "+base+"domains/check", s.authed(s.handleDomainCheck, false))
mux.Handle("GET "+base+"local-zones", s.authed(s.handleLocalZones, false))
mux.Handle("PUT "+base+"local-zones/{name}", s.authed(s.handleLocalZonePut, true))
mux.Handle("DELETE "+base+"local-zones/{name}", s.authed(s.handleLocalZoneDelete, true))
mux.Handle("GET "+base+"zones", s.authed(s.handleZones, false))
mux.Handle("DELETE "+base+"zones", s.authed(s.handleZoneDelete, true))
mux.Handle("POST "+base+"zones/records", s.authed(s.handleZoneRecordAdd, true))
mux.Handle("PUT "+base+"zones/records", s.authed(s.handleZoneRecordPut, true))
mux.Handle("DELETE "+base+"zones/records", s.authed(s.handleZoneRecordDelete, true))
mux.Handle("POST "+base+"zones/delegations", s.authed(s.handleZoneDelegationAdd, true))
mux.Handle("DELETE "+base+"zones/delegations", s.authed(s.handleZoneDelegationDelete, true))
mux.Handle("GET "+base+"clients", s.authed(s.handleClients, false))
mux.Handle("PUT "+base+"clients", s.authed(s.handleClientsPut, true))
mux.Handle("GET "+base+"upstreams", s.authed(s.handleUpstreams, false))
mux.Handle("PUT "+base+"upstreams", s.authed(s.handleUpstreamsPut, true))
mux.Handle("GET "+base+"logs", s.authed(s.handleLogs, false))
mux.Handle("GET "+base+"logs/stream", s.authed(s.handleLogStream, false))
mux.Handle("POST "+base+"logs/clear", s.authed(s.handleLogsClear, true))
mux.Handle("GET "+base+"cache", s.authed(s.handleCache, false))
mux.Handle("GET "+base+"cache/top", s.authed(s.handleCacheTop, false))
mux.Handle("POST "+base+"cache/flush", s.authed(s.handleCacheFlush, true))
mux.Handle("GET "+base+"settings", s.authed(s.handleSettingsGet, false))
mux.Handle("PUT "+base+"settings", s.authed(s.handleSettingsPut, true))
mux.Handle("POST "+base+"settings/validate", s.authed(s.handleSettingsValidate, false))
mux.Handle("POST "+base+"settings/reload", s.authed(s.handleSettingsReload, true))
mux.Handle("GET "+base+"whoami", s.authed(s.handleWhoami, false))
return mux
}
// recoverer turns a panic in a handler into a 500 instead of a dropped
// connection, and logs it.
func (s *Server) recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
s.log.Error("panic in api handler", "path", r.URL.Path, "panic", rec)
writeError(w, http.StatusInternalServerError, "internal error")
}
}()
next.ServeHTTP(w, r)
})
}
// cors adds the configured origins. A wildcard is allowed but echoed per
// request rather than as "*", so credentials keep working.
func (s *Server) cors(next http.Handler) http.Handler {
allowed := map[string]bool{}
wildcard := false
for _, o := range s.hooks.Store.Get().API.CORSOrigins {
if o == "*" {
wildcard = true
}
allowed[o] = true
}
if len(allowed) == 0 && !wildcard {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && (wildcard || allowed[origin]) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Add("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key, Authorization")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// public marks a handler as reachable without a principal.
func (s *Server) public(h http.HandlerFunc) http.Handler { return h }
// limited applies the per-IP rate limit to a handler that is reachable without
// a principal. The login endpoint is the reason the limit exists: guessing
// API keys is bounded by nothing but the attacker's patience, so the lockout
// the login handler records has to be able to stop the *next* attempt. It did
// not, because the limiter ran behind authed() and login is public, so N bad
// keys gave N 401s and the lockout only locked the caller out of the rest of
// the API.
func (s *Server) limited(h http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.allowRequest(w, r) {
return
}
h(w, r)
})
}
// cfg is a small accessor so handlers read the current config once.
// authed wraps a handler with authentication and, for mutations, a write
// permission check.
func (s *Server) authed(h http.HandlerFunc, write bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, err := s.principal(r)
if err != nil {
if s.hooks.Store.Get().API.Auth.Mode != "none" && !s.cfgTrustLoopback(r) {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
// auth mode none, or a trusted loopback client: run as a local
// principal.
p = Principal{Mode: "loopback", Name: "loopback", Loopback: true}
}
if write && !p.CanWrite() {
writeError(w, http.StatusForbidden, "this key is read-only")
return
}
if !s.allowRequest(w, r) {
return
}
ctx := context.WithValue(r.Context(), principalKey, p)
h(w, r.WithContext(ctx))
})
}
type ctxKey string
const principalKey ctxKey = "fancydns.principal"
// PrincipalFrom returns the authenticated principal, if any.
func PrincipalFrom(ctx context.Context) (Principal, bool) {
p, ok := ctx.Value(principalKey).(Principal)
return p, ok
}
// principal authenticates a request.
func (s *Server) principal(r *http.Request) (Principal, error) {
// API key in the header wins: it is the machine path.
if key := r.Header.Get("X-API-Key"); key != "" {
return s.auth.apiKey(key)
}
if h := r.Header.Get("Authorization"); h != "" {
scheme, value, ok := strings.Cut(h, " ")
if !ok {
return Principal{}, errors.New("malformed Authorization header")
}
switch strings.ToLower(scheme) {
case "bearer":
// Local sessions minted by /auth/login are 64-character hex
// strings. OIDC access tokens are compact JWTs (three segments).
// Keep key-based login sessions usable alongside OIDC for scripted
// clients, while sending JWT-shaped credentials to the verifier.
if s.auth.mode == "oidc" && strings.Count(value, ".") == 2 {
return s.auth.oidcPrincipal(value)
}
return s.auth.session(value)
case "basic":
user, pass, ok := r.BasicAuth()
if !ok {
return Principal{}, errors.New("malformed basic credentials")
}
return s.auth.basic(user, pass)
}
return Principal{}, fmt.Errorf("unsupported auth scheme %q", scheme)
}
if s.cfgTrustLoopback(r) {
return Principal{Mode: "loopback", Name: "loopback", Loopback: true}, nil
}
return Principal{}, errors.New("no credentials")
}
func (s *Server) cfgTrustLoopback(r *http.Request) bool {
cfg := s.hooks.Store.Get()
if !cfg.API.Auth.TrustLoopback {
return false
}
ip := s.clientIP(r)
a, err := netip.ParseAddr(ip)
if err != nil {
return false
}
return a.IsLoopback()
}
// clientIP resolves the client address, honouring X-Forwarded-For only from a
// trusted proxy. Trusting it from anyone would let a client spoof its identity
// past the ACL and the rate limiter.
func (s *Server) clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
addr, err := netip.ParseAddr(host)
if err != nil {
return host
}
if !s.isTrusted(addr) {
return addr.String()
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// Left-most entry is the original client.
first := strings.TrimSpace(strings.Split(xff, ",")[0])
if a, err := netip.ParseAddr(first); err == nil {
return a.String()
}
}
if xr := r.Header.Get("X-Real-IP"); xr != "" {
if a, err := netip.ParseAddr(strings.TrimSpace(xr)); err == nil {
return a.String()
}
}
return addr.String()
}
func (s *Server) isTrusted(a netip.Addr) bool {
for _, p := range s.trusted {
if p.Contains(a) {
return true
}
}
return false
}
// allowRequest applies the API rate limit per client IP.
func (s *Server) allowRequest(w http.ResponseWriter, r *http.Request) bool {
cfg := s.hooks.Store.Get()
if cfg.API.RateLimit.PerMinute <= 0 {
return true
}
ip := s.clientIP(r)
now := s.now()
s.limMu.Lock()
defer s.limMu.Unlock()
st, ok := s.limits[ip]
if !ok {
st = &limitState{window: now}
s.limits[ip] = st
}
if now.Before(st.lockedTill) {
w.Header().Set("Retry-After", strconv.Itoa(int(st.lockedTill.Sub(now).Seconds())+1))
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
return false
}
if now.Sub(st.window) >= time.Minute {
st.window = now
st.count = 0
}
st.count++
if st.count > cfg.API.RateLimit.PerMinute {
w.Header().Set("Retry-After", "60")
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
return false
}
// Opportunistic cleanup keeps the map bounded without a sweeper.
if len(s.limits) > 4096 {
for k, v := range s.limits {
if now.Sub(v.window) > 10*time.Minute {
delete(s.limits, k)
}
}
}
return true
}
// recordLoginFailure locks an IP out after repeated bad logins.
func (s *Server) recordLoginFailure(ip string) {
cfg := s.hooks.Store.Get()
if cfg.API.RateLimit.Failures <= 0 {
return
}
s.limMu.Lock()
defer s.limMu.Unlock()
st, ok := s.limits[ip]
if !ok {
st = &limitState{window: s.now()}
s.limits[ip] = st
}
st.failures++
if st.failures >= cfg.API.RateLimit.Failures {
st.lockedTill = s.now().Add(cfg.API.RateLimit.Lockout.D())
st.failures = 0
}
s.log.Warn("failed admin login", "client", ip)
}
func (s *Server) clearLoginFailures(ip string) {
s.limMu.Lock()
defer s.limMu.Unlock()
st, ok := s.limits[ip]
if !ok {
return
}
// Only the auth-failure state is reset. Deleting the whole entry would
// also reset the per-minute request window, so a single successful login
// in the middle of a burst would hand the client a fresh budget — which
// is exactly what an attacker holding one valid key wants.
st.failures = 0
st.lockedTill = time.Time{}
}
// protectMetrics gates /metrics behind an API key when configured.
func (s *Server) protectMetrics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cfg := s.hooks.Store.Get()
if !cfg.Metrics.Protect {
next.ServeHTTP(w, r)
return
}
if _, err := s.principal(r); err != nil {
writeError(w, http.StatusUnauthorized, "metrics are protected")
return
}
next.ServeHTTP(w, r)
})
}
// ---- response helpers -------------------------------------------------------
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(code)
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
// The status line is already sent, so the only useful thing left is a
// truncated-object fallback the client can detect.
_, _ = w.Write([]byte(`{"error":"response encoding failed"}`))
}
}
type apiError struct {
Error string `json:"error"`
Fields []string `json:"fields,omitempty"`
}
func writeError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, apiError{Error: msg})
}
func writeErrorFields(w http.ResponseWriter, code int, msg string, fields []string) {
writeJSON(w, code, apiError{Error: msg, Fields: fields})
}
// decodeJSON reads a JSON body with a size cap and strict unknown-field
// rejection, so a typo in a UI payload is an error rather than a silent no-op.
func decodeJSON(r *http.Request, v any) error {
defer func() { _ = r.Body.Close() }()
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 4<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return err
}
return nil
}
func queryInt(r *http.Request, key string, def int) int {
v := r.URL.Query().Get(key)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def
}
return n
}
func queryBool(r *http.Request, key string) bool {
v := strings.ToLower(r.URL.Query().Get(key))
return v == "1" || v == "true" || v == "yes"
}
func queryTime(r *http.Request, key string) time.Time {
v := r.URL.Query().Get(key)
if v == "" {
return time.Time{}
}
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
if n > 1e12 {
return time.UnixMilli(n)
}
return time.Unix(n, 0)
}
if t, err := time.Parse(time.RFC3339, v); err == nil {
return t
}
return time.Time{}
}
func isFileWritable(path string) bool {
if path == "" {
return false
}
f, err := os.OpenFile(path, os.O_WRONLY, 0)
if err != nil {
// A missing file in a writable directory is still writable.
dir := path
if i := strings.LastIndexByte(dir, '/'); i > 0 {
dir = dir[:i]
}
d, derr := os.Stat(dir)
if derr != nil {
return false
}
return d.IsDir()
}
_ = f.Close()
return true
}