package websrv import ( "fmt" "net" "net/http" "net/http/httputil" "net/url" "os" "path" "path/filepath" "strings" ) // NewHandler serves SPA files from webDir and reverse-proxies /api and /health to apiURL. func NewHandler(webDir string, apiURL string) (http.Handler, error) { absoluteWebDir, err := filepath.Abs(webDir) if err != nil { return nil, fmt.Errorf("resolve webdir: %w", err) } info, err := os.Stat(absoluteWebDir) if err != nil { return nil, fmt.Errorf("webdir: %w", err) } if !info.IsDir() { return nil, fmt.Errorf("webdir is not a directory: %s", absoluteWebDir) } parsedAPIURL, err := url.Parse(apiURL) if err != nil { return nil, fmt.Errorf("parse API_URL: %w", err) } if parsedAPIURL.Scheme == "" || parsedAPIURL.Host == "" { return nil, fmt.Errorf("API_URL must include scheme and host: %q", apiURL) } apiProxy := httputil.NewSingleHostReverseProxy(parsedAPIURL) defaultDirector := apiProxy.Director apiProxy.Director = func(request *http.Request) { originalHost := request.Host clientIP := clientIPFromRequest(request) defaultDirector(request) // Preserve the browser-facing host so the API can enforce public_hostname. if request.Header.Get("X-Forwarded-Host") == "" && originalHost != "" { request.Header.Set("X-Forwarded-Host", originalHost) } if request.Header.Get("X-Forwarded-Proto") == "" { if request.TLS != nil { request.Header.Set("X-Forwarded-Proto", "https") } else { request.Header.Set("X-Forwarded-Proto", "http") } } if clientIP != "" { prior := strings.TrimSpace(request.Header.Get("X-Forwarded-For")) if prior == "" { request.Header.Set("X-Forwarded-For", clientIP) } else { request.Header.Set("X-Forwarded-For", prior+", "+clientIP) } } } fileServer := http.FileServer(http.Dir(absoluteWebDir)) return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { requestPath := request.URL.Path if requestPath == "/health" || strings.HasPrefix(requestPath, "/api/") || requestPath == "/api" { apiProxy.ServeHTTP(writer, request) return } serveSPA(writer, request, absoluteWebDir, fileServer) }), nil } func clientIPFromRequest(request *http.Request) string { host, _, err := net.SplitHostPort(request.RemoteAddr) if err != nil { return request.RemoteAddr } return host } func serveSPA( writer http.ResponseWriter, request *http.Request, webDir string, fileServer http.Handler, ) { cleanedPath := path.Clean("/" + request.URL.Path) if cleanedPath == "/" { http.ServeFile(writer, request, filepath.Join(webDir, "index.html")) return } candidatePath := filepath.Join(webDir, filepath.FromSlash(cleanedPath)) if !isWithinDir(webDir, candidatePath) { http.NotFound(writer, request) return } fileInfo, err := os.Stat(candidatePath) if err != nil || fileInfo.IsDir() { http.ServeFile(writer, request, filepath.Join(webDir, "index.html")) return } fileServer.ServeHTTP(writer, request) } func isWithinDir(rootDir string, candidatePath string) bool { relativePath, err := filepath.Rel(rootDir, candidatePath) if err != nil { return false } return relativePath != ".." && !strings.HasPrefix(relativePath, ".."+string(os.PathSeparator)) }