mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 12:50:05 +01:00
Refactor routing logic (Chi can be a pain when you're managing large sets of routes, but its one of the better options when considering a potential gRPC future)
Upgrade API Generation to fully support OAS3.1
Update swagger interface to RapiDoc - the old swagger interface doesnt support OAS3.1 yet
Docs are now embedded as part of the UI - once logged in they pick up the cookies and org id from what gets set by the UI, but you can override it
Other updates include better portability of the db-studio
Signed-off-by: allanice001 <allanice001@gmail.com>
247 lines
6.1 KiB
Go
247 lines
6.1 KiB
Go
package web
|
|
|
|
import (
|
|
"embed"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// NOTE: Vite outputs to web/dist with assets in dist/assets.
|
|
// If you add more nested folders in the future, include them here too.
|
|
|
|
//go:embed dist
|
|
var distFS embed.FS
|
|
|
|
// spaFileSystem serves embedded dist/ files with SPA fallback to index.html
|
|
type spaFileSystem struct {
|
|
fs fs.FS
|
|
}
|
|
|
|
func (s spaFileSystem) Open(name string) (fs.File, error) {
|
|
// Normalize, strip leading slash
|
|
if strings.HasPrefix(name, "/") {
|
|
name = name[1:]
|
|
}
|
|
// Try exact file
|
|
f, err := s.fs.Open(name)
|
|
if err == nil {
|
|
return f, nil
|
|
}
|
|
|
|
// If the requested file doesn't exist, fall back to index.html for SPA routes
|
|
// BUT only if it's not obviously a static asset extension
|
|
ext := strings.ToLower(filepath.Ext(name))
|
|
switch ext {
|
|
case ".js", ".css", ".map", ".json", ".txt", ".ico", ".png", ".jpg", ".jpeg",
|
|
".svg", ".webp", ".gif", ".woff", ".woff2", ".ttf", ".otf", ".eot", ".wasm", ".br", ".gz":
|
|
return nil, fs.ErrNotExist
|
|
}
|
|
|
|
return s.fs.Open("index.html")
|
|
}
|
|
|
|
func newDistFS() (fs.FS, error) {
|
|
return fs.Sub(distFS, "dist")
|
|
}
|
|
|
|
// SPAHandler returns an http.Handler that serves the embedded UI (with caching)
|
|
func SPAHandler() (http.Handler, error) {
|
|
sub, err := newDistFS()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
spa := spaFileSystem{fs: sub}
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/api/") ||
|
|
r.URL.Path == "/api" ||
|
|
strings.HasPrefix(r.URL.Path, "/swagger") ||
|
|
strings.HasPrefix(r.URL.Path, "/db-studio") ||
|
|
strings.HasPrefix(r.URL.Path, "/debug/pprof") {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
raw := strings.TrimSpace(r.URL.Path)
|
|
if raw == "" || raw == "/" {
|
|
raw = "/index.html"
|
|
}
|
|
|
|
clean := path.Clean("/" + raw) // nosemgrep: autoglue.filesystem.no-path-clean
|
|
filePath := strings.TrimPrefix(clean, "/")
|
|
if filePath == "" {
|
|
filePath = "index.html"
|
|
}
|
|
|
|
// Try compressed variants for assets and HTML
|
|
// NOTE: we only change *Content-Encoding*; Content-Type derives from original ext
|
|
// Always vary on Accept-Encoding
|
|
w.Header().Add("Vary", "Accept-Encoding")
|
|
|
|
enc := r.Header.Get("Accept-Encoding")
|
|
if tryServeCompressed(w, r, spa, filePath, enc) {
|
|
return
|
|
}
|
|
|
|
// Fallback: normal open (or SPA fallback)
|
|
f, err := spa.Open(filePath)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
if strings.HasSuffix(filePath, ".html") {
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
} else {
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
}
|
|
|
|
info, _ := f.Stat()
|
|
modTime := time.Now()
|
|
if info != nil {
|
|
modTime = info.ModTime()
|
|
}
|
|
http.ServeContent(w, r, filePath, modTime, file{f})
|
|
}), nil
|
|
}
|
|
|
|
func tryServeCompressed(w http.ResponseWriter, r *http.Request, spa spaFileSystem, filePath, enc string) bool {
|
|
wantsBR := strings.Contains(enc, "br")
|
|
wantsGZ := strings.Contains(enc, "gzip")
|
|
|
|
type cand struct {
|
|
logical string // MIME/type decision uses this (uncompressed name)
|
|
physical string // actual file we open (with .br/.gz)
|
|
enc string
|
|
}
|
|
|
|
var cands []cand
|
|
|
|
// 1) direct compressed variant of requested path (rare for SPA routes, but cheap to try)
|
|
if wantsBR {
|
|
cands = append(cands, cand{logical: filePath, physical: filePath + ".br", enc: "br"})
|
|
}
|
|
if wantsGZ {
|
|
cands = append(cands, cand{logical: filePath, physical: filePath + ".gz", enc: "gzip"})
|
|
}
|
|
|
|
// 2) SPA route: fall back to compressed index.html
|
|
if filepath.Ext(filePath) == "" {
|
|
if wantsBR {
|
|
cands = append(cands, cand{logical: "index.html", physical: "index.html.br", enc: "br"})
|
|
}
|
|
if wantsGZ {
|
|
cands = append(cands, cand{logical: "index.html", physical: "index.html.gz", enc: "gzip"})
|
|
}
|
|
}
|
|
|
|
for _, c := range cands {
|
|
f, err := spa.fs.Open(c.physical) // open EXACT path so we don't accidentally get SPA fallback
|
|
if err != nil {
|
|
continue
|
|
}
|
|
defer f.Close()
|
|
|
|
// Cache headers
|
|
if strings.HasSuffix(c.logical, ".html") {
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
} else {
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
}
|
|
|
|
if ct := mimeByExt(path.Ext(c.logical)); ct != "" {
|
|
w.Header().Set("Content-Type", ct)
|
|
}
|
|
w.Header().Set("Content-Encoding", c.enc)
|
|
w.Header().Add("Vary", "Accept-Encoding")
|
|
|
|
info, _ := f.Stat()
|
|
modTime := time.Now()
|
|
if info != nil {
|
|
modTime = info.ModTime()
|
|
}
|
|
|
|
// Serve the precompressed bytes
|
|
http.ServeContent(w, r, c.physical, modTime, file{f})
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func serveIfExists(w http.ResponseWriter, r *http.Request, spa spaFileSystem, filePath, ext, encoding string) bool {
|
|
cf := filePath + ext
|
|
f, err := spa.Open(cf)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer f.Close()
|
|
|
|
// Set caching headers
|
|
if strings.HasSuffix(filePath, ".html") {
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
} else {
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
}
|
|
// Preserve original content type by extension of *uncompressed* file
|
|
if ct := mimeByExt(path.Ext(filePath)); ct != "" {
|
|
w.Header().Set("Content-Type", ct)
|
|
}
|
|
w.Header().Set("Content-Encoding", encoding)
|
|
|
|
info, _ := f.Stat()
|
|
modTime := time.Now()
|
|
if info != nil {
|
|
modTime = info.ModTime()
|
|
}
|
|
|
|
// Serve the compressed bytes as an io.ReadSeeker if possible
|
|
http.ServeContent(w, r, cf, modTime, file{f})
|
|
return true
|
|
}
|
|
|
|
func mimeByExt(ext string) string {
|
|
switch strings.ToLower(ext) {
|
|
case ".html":
|
|
return "text/html; charset=utf-8"
|
|
case ".js":
|
|
return "application/javascript"
|
|
case ".css":
|
|
return "text/css; charset=utf-8"
|
|
case ".json":
|
|
return "application/json"
|
|
case ".svg":
|
|
return "image/svg+xml"
|
|
case ".png":
|
|
return "image/png"
|
|
case ".jpg", ".jpeg":
|
|
return "image/jpeg"
|
|
case ".webp":
|
|
return "image/webp"
|
|
case ".ico":
|
|
return "image/x-icon"
|
|
case ".woff2":
|
|
return "font/woff2"
|
|
case ".woff":
|
|
return "font/woff"
|
|
default:
|
|
return "" // let Go sniff if empty
|
|
}
|
|
}
|
|
|
|
// file wraps fs.File to implement io.ReadSeeker if possible (for ServeContent)
|
|
type file struct{ fs.File }
|
|
|
|
func (f file) Seek(offset int64, whence int) (int64, error) {
|
|
if s, ok := f.File.(io.Seeker); ok {
|
|
return s.Seek(offset, whence)
|
|
}
|
|
// Fallback: not seekable
|
|
return 0, fs.ErrInvalid
|
|
}
|