Files
steveandClaude Opus 4.8 0f3dedab73 Address Gadfly review findings on Phase 0
Applied the fixes warranted by the adversarial review of PR #21 (all
findings graded in the gadfly store):

Store (highest impact):
- buildDSN always merges busy_timeout/journal_mode/foreign_keys pragmas
  into the DSN, even for file: URIs or paths with a query string — the
  prior passthrough silently disabled FK enforcement for those PANSY_DB
  values. Also escape '#' in the path and tighten in-memory detection to
  an exact ':memory:' token or mode=memory param (no substring misfire).
- migrate.go: detect duplicate migration versions with a clear error;
  wrap appliedVersions' rows.Err() with the store: prefix.

API:
- SetTrustedProxies failure now falls back to trust-none instead of
  leaving gin's trust-everyone default (X-Forwarded-For spoofing).
- SPA: 404 embedded directories (was a directory listing), 404 bare /api,
  add X-Content-Type-Options: nosniff, cache index.html bytes once at
  startup, single fs.Stat existence check, shared writeAPIError helper.
- Move gin.SetMode out of package init() into New().

Config: validate PANSY_PORT range (fall back to 8080 with a warning).

Web:
- api.ts: serialize the request body before the fetch try so a
  JSON.stringify failure isn't misreported as a network error.
- AppShell: move state-specific/conflicting utilities into
  active/inactiveProps so concatenated Tailwind classes don't collide.

Tests: added store DSN-pragma/memory-detection cases and SPA
directory-404 case. go build/vet/test clean (CGO off); web tsc + build
clean; re-verified in a browser (SPA, deep links, /assets 404, nav).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 15:19:30 -04:00

92 lines
3.1 KiB
Go

package api
import (
"io/fs"
"net/http"
"path"
"strings"
"github.com/gin-gonic/gin"
)
// RegisterSPA serves the embedded single-page app for every route gin didn't
// otherwise match. A request whose path maps to a real file (e.g. a hashed asset
// bundle) is served directly; any other non-API path falls back to index.html so
// client-side deep links like /gardens/42 load the app. Unmatched /api paths
// return a JSON 404 rather than HTML.
func RegisterSPA(r *gin.Engine, dist fs.FS) {
fileServer := http.FileServer(http.FS(dist))
// index.html is served on every client-route deep link; read it once at
// startup rather than re-reading the embedded FS per request. A build without
// it is a packaging bug, so fail loudly.
index, err := fs.ReadFile(dist, "index.html")
if err != nil {
panic("api: embedded web build missing index.html: " + err.Error())
}
r.NoRoute(func(c *gin.Context) {
reqPath := c.Request.URL.Path
// API paths (including a bare "/api") are never the SPA: a miss is a real
// JSON 404, not the app shell.
if reqPath == "/api" || strings.HasPrefix(reqPath, "/api/") {
writeAPIError(c, http.StatusNotFound, "NOT_FOUND", "no such endpoint")
return
}
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
writeAPIError(c, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
return
}
c.Header("X-Content-Type-Options", "nosniff")
name := strings.TrimPrefix(path.Clean(reqPath), "/")
if name == "" {
serveIndex(c, index)
return
}
if info, statErr := fs.Stat(dist, name); statErr == nil {
// An embedded directory is neither a client route nor a servable file;
// serving it would leak a directory listing, so it is a 404.
if info.IsDir() {
writeAPIError(c, http.StatusNotFound, "NOT_FOUND", "not found")
return
}
// Vite emits content-hashed filenames under assets/, so those are safe
// to cache forever; index.html must always revalidate to pick up new builds.
if strings.HasPrefix(name, "assets/") {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
} else {
c.Header("Cache-Control", "no-cache")
}
fileServer.ServeHTTP(c.Writer, c.Request)
return
}
// A missing file under assets/ is a genuine 404, not a client route:
// falling back to index.html would return HTML with a 200 for a .js/.css
// request, which the browser rejects with a confusing MIME-type error.
if strings.HasPrefix(name, "assets/") {
writeAPIError(c, http.StatusNotFound, "NOT_FOUND", "asset not found")
return
}
serveIndex(c, index)
})
}
// serveIndex writes the cached index.html as the SPA fallback (no-cache so new
// builds are picked up). net/http suppresses the body for HEAD requests.
func serveIndex(c *gin.Context, index []byte) {
c.Header("Cache-Control", "no-cache")
c.Data(http.StatusOK, "text/html; charset=utf-8", index)
}
// writeAPIError writes pansy's standard JSON error envelope.
func writeAPIError(c *gin.Context, status int, code, message string) {
c.JSON(status, gin.H{"error": gin.H{"code": code, "message": message}})
}