f5f7db6c2a
Add a multi-stage Dockerfile that builds the SvelteKit SPA (adapter-static, no Node runtime in the final image) and the Go server, then ships an Alpine runtime that serves both the static frontend and the API on one port. - Stage 1 (node): npm ci + build → static SPA (index.html, _app, fonts, sw) - Stage 2 (golang): CGO_ENABLED=0 static binary (image processing is pure Go) - Stage 3 (alpine): + ffmpeg for video thumbnails, non-root user, /data volume, healthcheck on /health; secrets passed at runtime, not baked in To serve the SPA on the API port, the Go server now optionally hosts static files behind a new STATIC_DIR env var: a request maps to a real file when one exists, otherwise falls back to index.html for client-side routes; unknown /api/ paths still return JSON 404. Empty STATIC_DIR (local dev) keeps the API standalone while Vite serves the UI. Cache-Control is tuned to adapter-static output (immutable hashed assets, no-cache service worker) and .webmanifest is registered so nosniff doesn't reject the PWA manifest. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
75 lines
2.3 KiB
Go
75 lines
2.3 KiB
Go
package handler
|
|
|
|
import (
|
|
"mime"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
func init() {
|
|
// Go's mime table doesn't know .webmanifest; register it so the PWA manifest
|
|
// is served as JSON and isn't rejected by the X-Content-Type-Options header.
|
|
_ = mime.AddExtensionType(".webmanifest", "application/manifest+json")
|
|
}
|
|
|
|
// spaHandler serves the built single-page app from dir. It is wired as the
|
|
// router's NoRoute handler, so it only sees requests that matched no API route.
|
|
//
|
|
// A request whose path maps to a real file on disk is served directly (with
|
|
// cache headers tuned to SvelteKit's adapter-static output). Anything else
|
|
// falls back to index.html so the client-side router can resolve deep links
|
|
// like /pools/123. Unknown /api/ paths return a JSON 404 instead of the HTML
|
|
// shell, keeping API error responses machine-readable.
|
|
func spaHandler(dir string) gin.HandlerFunc {
|
|
indexPath := filepath.Join(dir, "index.html")
|
|
|
|
return func(c *gin.Context) {
|
|
reqPath := c.Request.URL.Path
|
|
|
|
if strings.HasPrefix(reqPath, "/api/") {
|
|
c.JSON(http.StatusNotFound, errorBody{
|
|
Code: "not_found",
|
|
Message: "resource not found",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Resolve the request to a path inside dir. Cleaning an absolute path
|
|
// collapses any "../" segments before the join, so the result can never
|
|
// escape dir — this is the traversal guard.
|
|
clean := path.Clean("/" + reqPath)
|
|
target := filepath.Join(dir, filepath.FromSlash(clean))
|
|
|
|
if info, err := os.Stat(target); err == nil && !info.IsDir() {
|
|
c.Header("Cache-Control", cacheControl(clean))
|
|
c.File(target)
|
|
return
|
|
}
|
|
|
|
// SPA fallback: serve the shell, never cached so a new deploy is picked
|
|
// up immediately on the next navigation.
|
|
c.Header("Cache-Control", "no-cache")
|
|
c.File(indexPath)
|
|
}
|
|
}
|
|
|
|
// cacheControl returns the Cache-Control value for a served static asset.
|
|
// SvelteKit emits content-hashed files under /_app/immutable — those are safe
|
|
// to cache forever. The service worker must never be cached, or clients pin to
|
|
// a stale shell. Everything else gets a short, revalidated TTL.
|
|
func cacheControl(p string) string {
|
|
switch {
|
|
case strings.HasPrefix(p, "/_app/immutable/"):
|
|
return "public, max-age=31536000, immutable"
|
|
case p == "/service-worker.js":
|
|
return "no-cache"
|
|
default:
|
|
return "public, max-age=3600"
|
|
}
|
|
}
|