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
This commit is contained in:
+7
-6
@@ -15,21 +15,22 @@ import (
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Quiet gin's default debug output; we log via slog-gin instead.
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
// New builds the gin engine with the standard middleware stack and registers the
|
||||
// API routes. The embedded SPA fallback is registered separately by the caller
|
||||
// via RegisterSPA (see spa.go) so the API can be built and tested without a web
|
||||
// build present.
|
||||
func New(cfg *config.Config) *gin.Engine {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(sloggin.New(slog.Default()), gin.Recovery())
|
||||
|
||||
if err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {
|
||||
slog.Warn("api: failed to set trusted proxies", "error", err)
|
||||
// Do not leave gin's trust-everyone default active on a parse failure —
|
||||
// that would let any client spoof X-Forwarded-For. Fall back to trusting
|
||||
// no proxies, which is also the behavior when none are configured.
|
||||
slog.Error("api: invalid trusted proxies, trusting none", "error", err)
|
||||
_ = r.SetTrustedProxies(nil)
|
||||
}
|
||||
|
||||
v1 := r.Group("/api/v1")
|
||||
|
||||
+35
-22
@@ -17,30 +17,44 @@ import (
|
||||
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
|
||||
|
||||
if strings.HasPrefix(reqPath, "/api/") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{
|
||||
"code": "NOT_FOUND", "message": "no such endpoint",
|
||||
}})
|
||||
// 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 {
|
||||
c.JSON(http.StatusMethodNotAllowed, gin.H{"error": gin.H{
|
||||
"code": "METHOD_NOT_ALLOWED", "message": "method not allowed",
|
||||
}})
|
||||
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 == "" {
|
||||
name = "index.html"
|
||||
serveIndex(c, index)
|
||||
return
|
||||
}
|
||||
|
||||
if f, err := dist.Open(name); err == nil {
|
||||
f.Close()
|
||||
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/") {
|
||||
@@ -56,23 +70,22 @@ func RegisterSPA(r *gin.Engine, dist fs.FS) {
|
||||
// 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/") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{
|
||||
"code": "NOT_FOUND", "message": "asset not found",
|
||||
}})
|
||||
writeAPIError(c, http.StatusNotFound, "NOT_FOUND", "asset not found")
|
||||
return
|
||||
}
|
||||
|
||||
serveIndex(c, dist)
|
||||
serveIndex(c, index)
|
||||
})
|
||||
}
|
||||
|
||||
// serveIndex writes index.html as the SPA fallback for an unmatched client route.
|
||||
func serveIndex(c *gin.Context, dist fs.FS) {
|
||||
data, err := fs.ReadFile(dist, "index.html")
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "index.html missing from build")
|
||||
return
|
||||
}
|
||||
// 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", data)
|
||||
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}})
|
||||
}
|
||||
|
||||
@@ -77,6 +77,16 @@ func TestSPAMissingAssetIs404(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPADirectoryIs404NotListing(t *testing.T) {
|
||||
// A request that resolves to an embedded directory must 404, never a listing.
|
||||
for _, p := range []string{"/assets", "/assets/"} {
|
||||
w := do(t, testEngine(), http.MethodGet, p)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("GET %s status = %d, want 404", p, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPAUnmatchedAPIIs404JSON(t *testing.T) {
|
||||
w := do(t, testEngine(), http.MethodGet, "/api/v1/nope")
|
||||
if w.Code != http.StatusNotFound {
|
||||
|
||||
Reference in New Issue
Block a user