This commit is contained in:
@@ -132,7 +132,7 @@ func validateAdminPaths(adminPath, apiPrefix string) error {
|
||||
return errors.New("admin API prefix cannot equal admin page path")
|
||||
}
|
||||
|
||||
for _, asset := range []string{"app.css", "app.js"} {
|
||||
for _, asset := range []string{"app.css", "config.js", "js"} {
|
||||
assetPath := strings.TrimRight(adminPath, "/") + "/" + asset
|
||||
if apiPrefix == assetPath || strings.HasPrefix(apiPrefix+"/", assetPath+"/") {
|
||||
return errors.New("admin API prefix cannot be under static asset path")
|
||||
|
||||
@@ -60,8 +60,12 @@ func TestParseReturnsErrors(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "api prefix under asset path",
|
||||
env: map[string]string{EnvAPIPrefix: "/admin/app.js/api"},
|
||||
name: "api prefix under js asset path",
|
||||
env: map[string]string{EnvAPIPrefix: "/admin/js/api"},
|
||||
},
|
||||
{
|
||||
name: "api prefix under config asset path",
|
||||
env: map[string]string{EnvAPIPrefix: "/admin/config.js/api"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
package adminhttp
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
staticIndexFile = "admin_static/index.html"
|
||||
staticCSSFile = "admin_static/app.css"
|
||||
staticJSFile = "admin_static/app.js"
|
||||
defaultStaticDir = "cmd/gateway/admin_static"
|
||||
staticConfigFile = "config.js"
|
||||
staticIndexFile = "index.html"
|
||||
)
|
||||
|
||||
type GatewayHandlerOptions struct {
|
||||
AdminPath string
|
||||
AdminAPIPrefix string
|
||||
Assets fs.FS
|
||||
StaticDir string
|
||||
APIHandler http.HandlerFunc
|
||||
WebSocketEnabled bool
|
||||
WebSocketPath string
|
||||
@@ -68,40 +71,67 @@ func serveAdminStatic(w http.ResponseWriter, r *http.Request, opts GatewayHandle
|
||||
}
|
||||
|
||||
if r.URL.Path == opts.AdminPath {
|
||||
serveAdminIndex(w, opts)
|
||||
serveAdminFile(w, r, opts, staticIndexFile)
|
||||
return
|
||||
}
|
||||
|
||||
rel := strings.TrimPrefix(r.URL.Path, opts.AdminPath)
|
||||
switch rel {
|
||||
case "app.css":
|
||||
serveAdminFile(w, r, opts.Assets, staticCSSFile, "text/css; charset=utf-8")
|
||||
case "app.js":
|
||||
serveAdminFile(w, r, opts.Assets, staticJSFile, "application/javascript; charset=utf-8")
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func serveAdminIndex(w http.ResponseWriter, opts GatewayHandlerOptions) {
|
||||
data, err := fs.ReadFile(opts.Assets, staticIndexFile)
|
||||
if err != nil {
|
||||
WriteAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
if rel == staticConfigFile {
|
||||
serveAdminConfig(w, opts)
|
||||
return
|
||||
}
|
||||
html := strings.ReplaceAll(string(data), "__ADMIN_API_PREFIX__", opts.AdminAPIPrefix)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write([]byte(html))
|
||||
serveAdminFile(w, r, opts, rel)
|
||||
}
|
||||
|
||||
func serveAdminFile(w http.ResponseWriter, r *http.Request, assets fs.FS, name, contentType string) {
|
||||
data, err := fs.ReadFile(assets, name)
|
||||
if err != nil {
|
||||
func serveAdminConfig(w http.ResponseWriter, opts GatewayHandlerOptions) {
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write([]byte(`window.MCGatewayAdmin={"apiPrefix":` + strconv.Quote(opts.AdminAPIPrefix) + `};`))
|
||||
}
|
||||
|
||||
func serveAdminFile(w http.ResponseWriter, r *http.Request, opts GatewayHandlerOptions, rel string) {
|
||||
name, ok := cleanStaticPath(rel)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
file := filepath.Join(staticDir(opts), name)
|
||||
info, err := os.Stat(file)
|
||||
if err != nil || info.IsDir() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(data)
|
||||
http.ServeFile(w, r, file)
|
||||
}
|
||||
|
||||
func cleanStaticPath(rel string) (string, bool) {
|
||||
if rel == "" {
|
||||
return "", false
|
||||
}
|
||||
cleaned := path.Clean("/" + rel)
|
||||
if cleaned == "/" || strings.HasPrefix(cleaned, "/../") {
|
||||
return "", false
|
||||
}
|
||||
name := strings.TrimPrefix(cleaned, "/")
|
||||
if name == staticConfigFile {
|
||||
return "", false
|
||||
}
|
||||
return name, true
|
||||
}
|
||||
|
||||
func staticDir(opts GatewayHandlerOptions) string {
|
||||
if strings.TrimSpace(opts.StaticDir) != "" {
|
||||
return opts.StaticDir
|
||||
}
|
||||
if value := strings.TrimSpace(os.Getenv("MC_GATEWAY_ADMIN_STATIC_DIR")); value != "" {
|
||||
return value
|
||||
}
|
||||
if _, err := os.Stat(defaultStaticDir); err == nil {
|
||||
return defaultStaticDir
|
||||
}
|
||||
if _, err := os.Stat("admin_static"); err == nil {
|
||||
return "admin_static"
|
||||
}
|
||||
return defaultStaticDir
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestDecodeJSONRequest(t *testing.T) {
|
||||
@@ -117,16 +118,16 @@ func TestRequestSourceIP(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewGatewayHandlerServesAdminAndAPI(t *testing.T) {
|
||||
assets := fstest.MapFS{
|
||||
staticIndexFile: {Data: []byte(`<html data-api-prefix="__ADMIN_API_PREFIX__"></html>`)},
|
||||
staticCSSFile: {Data: []byte(`body{color:red}`)},
|
||||
staticJSFile: {Data: []byte(`console.log("admin")`)},
|
||||
}
|
||||
staticDir := writeTestAdminStatic(t, map[string]string{
|
||||
"index.html": "<html><script src=\"config.js\"></script></html>",
|
||||
"app.css": "body{color:red}",
|
||||
"js/main.js": `console.log("admin")`,
|
||||
})
|
||||
apiCalled := false
|
||||
handler := NewGatewayHandler(GatewayHandlerOptions{
|
||||
AdminPath: "/ops/",
|
||||
AdminAPIPrefix: "/ops/api",
|
||||
Assets: assets,
|
||||
StaticDir: staticDir,
|
||||
APIHandler: func(w http.ResponseWriter, r *http.Request) {
|
||||
apiCalled = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -149,11 +150,8 @@ func TestNewGatewayHandlerServesAdminAndAPI(t *testing.T) {
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("admin page status = %d, body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), `data-api-prefix="/ops/api"`) {
|
||||
t.Fatalf("admin page = %q, want API prefix", resp.Body.String())
|
||||
}
|
||||
if got := resp.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("admin page Cache-Control = %q, want no-store", got)
|
||||
if !strings.Contains(resp.Body.String(), `script src="config.js"`) {
|
||||
t.Fatalf("admin page = %q, want static index", resp.Body.String())
|
||||
}
|
||||
|
||||
resp = httptest.NewRecorder()
|
||||
@@ -163,6 +161,33 @@ func TestNewGatewayHandlerServesAdminAndAPI(t *testing.T) {
|
||||
t.Fatalf("css response status=%d body=%q", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
resp = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/ops/js/main.js", nil)
|
||||
handler.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusOK || !strings.Contains(resp.Body.String(), `console.log("admin")`) {
|
||||
t.Fatalf("js response status=%d body=%q", resp.Code, resp.Body.String())
|
||||
}
|
||||
if got := resp.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("js Cache-Control = %q, want no-store", got)
|
||||
}
|
||||
|
||||
resp = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/ops/js/", nil)
|
||||
handler.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("js directory status=%d, want %d", resp.Code, http.StatusNotFound)
|
||||
}
|
||||
|
||||
resp = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/ops/config.js", nil)
|
||||
handler.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusOK || strings.TrimSpace(resp.Body.String()) != `window.MCGatewayAdmin={"apiPrefix":"/ops/api"};` {
|
||||
t.Fatalf("config response status=%d body=%q", resp.Code, resp.Body.String())
|
||||
}
|
||||
if got := resp.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("config Cache-Control = %q, want no-store", got)
|
||||
}
|
||||
|
||||
resp = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/ops/", nil)
|
||||
handler.ServeHTTP(resp, req)
|
||||
@@ -182,13 +207,11 @@ func TestNewGatewayHandlerServesAdminAndAPI(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewGatewayHandlerRegistersWebSocketWhenPathDoesNotConflict(t *testing.T) {
|
||||
assets := fstest.MapFS{
|
||||
staticIndexFile: {Data: []byte(``)},
|
||||
}
|
||||
staticDir := writeTestAdminStatic(t, map[string]string{"index.html": ""})
|
||||
handler := NewGatewayHandler(GatewayHandlerOptions{
|
||||
AdminPath: "/admin/",
|
||||
AdminAPIPrefix: "/admin/api",
|
||||
Assets: assets,
|
||||
StaticDir: staticDir,
|
||||
APIHandler: func(w http.ResponseWriter, r *http.Request) {},
|
||||
WebSocketEnabled: true,
|
||||
WebSocketPath: "/ws",
|
||||
@@ -205,6 +228,21 @@ func TestNewGatewayHandlerRegistersWebSocketWhenPathDoesNotConflict(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestAdminStatic(t *testing.T, files map[string]string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for name, data := range files {
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(%q) error = %v", filepath.Dir(path), err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(%q) error = %v", path, err)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestWebSocketPathConflictsWithAdmin(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
Reference in New Issue
Block a user