List and delete accounts with last-admin and Administrators-group guards, and expose editable network settings via the configuration UI.
119 lines
3.2 KiB
Go
119 lines
3.2 KiB
Go
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))
|
|
}
|