diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index c0968736..2a362816 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -139,3 +139,20 @@ HTTPS connection. ```bash ./build/ratel -tls_crt example.crt -tls_key example.key ``` + +## Serving under a URL prefix + +When hosting Ratel behind a reverse proxy under a subpath (e.g. `https://example.com/ratel/`), set +the `-url-prefix` flag (or the `RATEL_URL_PREFIX` environment variable) so the UI and its static +assets are served under that prefix: + +```bash +./build/ratel -url-prefix /ratel +# or +RATEL_URL_PREFIX=/ratel ./build/ratel +``` + +With a prefix set, all routes move under the prefix (`/ratel/`, `/ratel/static/...`), requests to +the bare prefix (`/ratel`) redirect to `/ratel/`, asset URLs in the served `index.html` are +rewritten to include the prefix, and any path outside the prefix returns a 404 pointing at the +prefix. The flag value is normalized to have a leading slash and no trailing slash. diff --git a/server/server.go b/server/server.go index 1b113eda..04a21b55 100644 --- a/server/server.go +++ b/server/server.go @@ -13,6 +13,7 @@ import ( "log" "net/http" "os" + "regexp" "strings" ) @@ -34,6 +35,8 @@ var ( tlsKey string listenAddr string + + urlPrefix string ) // Run starts the server. @@ -41,17 +44,43 @@ func Run() { parseFlags() indexContent := prepareIndexContent() - http.HandleFunc("/", makeMainHandler(indexContent)) + mux := newServeMux(indexContent, urlPrefix) addrStr := fmt.Sprintf("%s:%d", listenAddr, port) + if urlPrefix != "" { + log.Printf("Serving under URL prefix %s/", urlPrefix) + } log.Printf("Listening on %s...", addrStr) switch { case tlsCrt != "": - log.Fatalln(http.ListenAndServeTLS(addrStr, tlsCrt, tlsKey, nil)) + log.Fatalln(http.ListenAndServeTLS(addrStr, tlsCrt, tlsKey, mux)) default: - log.Fatalln(http.ListenAndServe(addrStr, nil)) + log.Fatalln(http.ListenAndServe(addrStr, mux)) + } +} + +// newServeMux builds the HTTP routing for the Ratel server. With an empty +// prefix all content is served from the root, preserving historic behavior. +// With a prefix (e.g. "/ratel") all content is served under that prefix, the +// bare prefix redirects to "/", and any other path returns 404 with a +// hint pointing at the prefix. +func newServeMux(indexContent *content, prefix string) *http.ServeMux { + mux := http.NewServeMux() + mainHandler := makeMainHandler(indexContent) + + if prefix == "" { + mux.Handle("/", mainHandler) + return mux } + + mux.Handle(prefix+"/", http.StripPrefix(prefix, mainHandler)) + mux.Handle(prefix, http.RedirectHandler(prefix+"/", http.StatusMovedPermanently)) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, fmt.Sprintf("Not found. Ratel is served under %s/", prefix), + http.StatusNotFound) + }) + return mux } func parseFlags() { @@ -61,6 +90,9 @@ func parseFlags() { tlsCrtPtr := flag.String("tls_crt", "", "TLS cert for serving HTTPS requests.") tlsKeyPtr := flag.String("tls_key", "", "TLS key for serving HTTPS requests.") listenAddrPtr := flag.String("listen-addr", defaultAddr, "Address Ratel server should listen on.") + urlPrefixPtr := flag.String("url-prefix", "", + "URL path prefix under which Ratel is served, e.g. \"/ratel\" "+ + "(falls back to the RATEL_URL_PREFIX environment variable).") flag.Parse() @@ -84,6 +116,26 @@ func parseFlags() { tlsKey = *tlsKeyPtr listenAddr = *listenAddrPtr + + prefix := *urlPrefixPtr + if prefix == "" { + prefix = os.Getenv("RATEL_URL_PREFIX") + } + urlPrefix = normalizeURLPrefix(prefix) +} + +// normalizeURLPrefix ensures a prefix has a leading slash and no trailing +// slash. Empty input and "/" normalize to "" (no prefix). +func normalizeURLPrefix(prefix string) string { + prefix = strings.TrimSpace(prefix) + prefix = strings.TrimRight(prefix, "/") + if prefix == "" { + return "" + } + if !strings.HasPrefix(prefix, "/") { + prefix = "/" + prefix + } + return prefix } func prepareIndexContent() *content { @@ -118,10 +170,35 @@ func prepareIndexContent() *content { return &content{ name: info.Name(), modTime: info.ModTime(), - bs: buf.Bytes(), + bs: rewriteURLPrefix(buf.Bytes(), urlPrefix), } } +// hrefSrcRe matches root-relative URLs in href/src attributes, e.g. +// href="/favicon.ico" or src="/static/js/main.js". It deliberately does not +// match protocol-relative URLs such as href="//cdn.example.com/x.js". +var hrefSrcRe = regexp.MustCompile(`\b(href|src)="(/(?:[^/"][^"]*)?)"`) + +// rewriteURLPrefix rewrites root-relative asset URLs in the index.html +// payload so they resolve when Ratel is served under a URL prefix. +func rewriteURLPrefix(bs []byte, prefix string) []byte { + if prefix == "" { + return bs + } + + out := hrefSrcRe.ReplaceAllFunc(bs, func(m []byte) []byte { + sub := hrefSrcRe.FindSubmatch(m) + return []byte(string(sub[1]) + `="` + prefix + string(sub[2]) + `"`) + }) + + // index.html injects the fallback loader script via an absolute path in + // inline JavaScript: injectJs('/loader.js'). + out = bytes.ReplaceAll(out, []byte(`'/loader.js'`), + []byte(`'`+prefix+`/loader.js'`)) + + return out +} + func makeMainHandler(indexContent *content) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/") diff --git a/server/server_test.go b/server/server_test.go new file mode 100644 index 00000000..5c7c2a66 --- /dev/null +++ b/server/server_test.go @@ -0,0 +1,200 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package server + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +const testIndexHTML = ` + + + + + + + + +Go to the release selection screen + + +` + +func testContent() *content { + return &content{ + name: "index.html", + modTime: time.Now(), + bs: []byte(testIndexHTML), + } +} + +func prefixedTestContent(prefix string) *content { + return &content{ + name: "index.html", + modTime: time.Now(), + bs: rewriteURLPrefix([]byte(testIndexHTML), prefix), + } +} + +func get(t *testing.T, mux *http.ServeMux, path string) (*http.Response, string) { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + resp := w.Result() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("reading response body for %s: %v", path, err) + } + return resp, string(body) +} + +// anyAssetPath returns the path of some embedded non-index asset, to verify +// static asset routing against the real bindata contents. +func anyAssetPath(t *testing.T) string { + t.Helper() + for _, name := range AssetNames() { + if name != indexPath { + return name + } + } + t.Skip("no non-index assets embedded") + return "" +} + +func TestNormalizeURLPrefix(t *testing.T) { + cases := []struct { + in, want string + }{ + {"", ""}, + {"/", ""}, + {"//", ""}, + {"ratel", "/ratel"}, + {"/ratel", "/ratel"}, + {"/ratel/", "/ratel"}, + {"ratel/", "/ratel"}, + {" /ratel ", "/ratel"}, + {"/a/b/", "/a/b"}, + } + for _, c := range cases { + if got := normalizeURLPrefix(c.in); got != c.want { + t.Errorf("normalizeURLPrefix(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestRewriteURLPrefix(t *testing.T) { + out := string(rewriteURLPrefix([]byte(testIndexHTML), "/ratel")) + + for _, want := range []string{ + `href="/ratel/favicon.ico"`, + `href="/ratel/3rdpartystatic/codemirror/neo.css"`, + `src="/ratel/static/js/main.js"`, + `href="/ratel/?nocookie"`, + `injectJs('/ratel/loader.js')`, + // Protocol-relative URLs must not be rewritten. + `href="//cdn.example.com/external.css"`, + } { + if !strings.Contains(out, want) { + t.Errorf("rewritten html missing %q\nhtml:\n%s", want, out) + } + } +} + +func TestRewriteURLPrefixEmptyIsNoop(t *testing.T) { + if out := string(rewriteURLPrefix([]byte(testIndexHTML), "")); out != testIndexHTML { + t.Errorf("rewriteURLPrefix with empty prefix changed the payload") + } +} + +func TestNoPrefixServesIndexAtRoot(t *testing.T) { + mux := newServeMux(testContent(), "") + + for _, path := range []string{"/", "/index.html"} { + resp, body := get(t, mux, path) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET %s status = %d, want 200", path, resp.StatusCode) + } + if !strings.Contains(body, `href="/favicon.ico"`) { + t.Errorf("GET %s: asset paths must stay unprefixed", path) + } + } +} + +func TestNoPrefixServesStaticAsset(t *testing.T) { + mux := newServeMux(testContent(), "") + + asset := anyAssetPath(t) + resp, _ := get(t, mux, "/"+asset) + if resp.StatusCode != http.StatusOK { + t.Errorf("GET /%s status = %d, want 200", asset, resp.StatusCode) + } +} + +func TestPrefixServesRewrittenIndex(t *testing.T) { + mux := newServeMux(prefixedTestContent("/ratel"), "/ratel") + + for _, path := range []string{"/ratel/", "/ratel/index.html"} { + resp, body := get(t, mux, path) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET %s status = %d, want 200", path, resp.StatusCode) + } + if !strings.Contains(body, `src="/ratel/static/js/main.js"`) { + t.Errorf("GET %s: body missing prefixed asset path", path) + } + if strings.Contains(body, `href="/favicon.ico"`) { + t.Errorf("GET %s: body still contains unprefixed asset path", path) + } + } +} + +func TestPrefixBareRedirectsToSlash(t *testing.T) { + mux := newServeMux(prefixedTestContent("/ratel"), "/ratel") + + resp, _ := get(t, mux, "/ratel") + if resp.StatusCode != http.StatusMovedPermanently { + t.Fatalf("GET /ratel status = %d, want %d", + resp.StatusCode, http.StatusMovedPermanently) + } + if loc := resp.Header.Get("Location"); loc != "/ratel/" { + t.Errorf("GET /ratel Location = %q, want %q", loc, "/ratel/") + } +} + +func TestPrefixRootReturns404(t *testing.T) { + mux := newServeMux(prefixedTestContent("/ratel"), "/ratel") + + for _, path := range []string{"/", "/favicon.ico", "/ratelx"} { + resp, body := get(t, mux, path) + if resp.StatusCode != http.StatusNotFound { + t.Errorf("GET %s status = %d, want 404", path, resp.StatusCode) + } + if path == "/" && !strings.Contains(body, "/ratel/") { + t.Errorf("GET / body should hint at the prefix, got %q", body) + } + } +} + +func TestPrefixServesStaticAsset(t *testing.T) { + mux := newServeMux(prefixedTestContent("/ratel"), "/ratel") + + asset := anyAssetPath(t) + resp, _ := get(t, mux, "/ratel/"+asset) + if resp.StatusCode != http.StatusOK { + t.Errorf("GET /ratel/%s status = %d, want 200", asset, resp.StatusCode) + } + + // The same asset must not resolve outside the prefix. + resp, _ = get(t, mux, "/"+asset) + if resp.StatusCode != http.StatusNotFound { + t.Errorf("GET /%s status = %d, want 404", asset, resp.StatusCode) + } +}