Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions INSTRUCTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
85 changes: 81 additions & 4 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"log"
"net/http"
"os"
"regexp"
"strings"
)

Expand All @@ -34,24 +35,52 @@ var (
tlsKey string

listenAddr string

urlPrefix string
)

// Run starts the server.
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 "<prefix>/", 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() {
Expand All @@ -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()

Expand All @@ -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 {
Expand Down Expand Up @@ -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, "/")
Expand Down
200 changes: 200 additions & 0 deletions server/server_test.go
Original file line number Diff line number Diff line change
@@ -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 = `<!DOCTYPE html>
<html>
<head>
<link rel="shortcut icon" href="/favicon.ico">
<link rel="stylesheet" href="/3rdpartystatic/codemirror/neo.css" />
<link rel="stylesheet" href="//cdn.example.com/external.css" />
<script src="/static/js/main.js"></script>
</head>
<body>
<a href="/?nocookie">Go to the release selection screen</a>
<script>injectJs('/loader.js');</script>
</body>
</html>`

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)
}
}
Loading