Refactor admin frontend build
Some checks failed
Docker Image / docker (push) Has been cancelled

This commit is contained in:
2026-06-25 21:16:11 +08:00
parent bf6db8c455
commit ecb682bb41
34 changed files with 1474 additions and 692 deletions

View File

@@ -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")

View File

@@ -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"},
},
}

View File

@@ -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
}

View File

@@ -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