Skip to content

Commit 70b986d

Browse files
committed
serve UI HTML for wildcard or missing Accept header
Fixes #485.
1 parent 79fdd1b commit 70b986d

3 files changed

Lines changed: 119 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
- Prevent double slash in URLs for root path prefix. Thanks [Jan Kott](https://github.com/boostvolt)! [PR #487](https://github.com/riverqueue/riverui/pull/487)
13+
- Serve UI HTML for wildcard or missing Accept headers and return 406 for explicit non-HTML requests. Fixes #485. [PR #XXX](https://github.com/riverqueue/riverui/pull/XXX).
1314

1415
## [v0.14.0] - 2026-01-02
1516

spa_response_writer.go

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import (
66
"fmt"
77
"html/template"
88
"io"
9+
"mime"
910
"net/http"
11+
"strconv"
1012
"strings"
1113
)
1214

@@ -30,9 +32,9 @@ func intercept404(handler, on404 http.Handler) http.Handler {
3032
func serveIndexHTML(devMode bool, manifest map[string]any, pathPrefix string, files http.FileSystem) http.HandlerFunc {
3133
return func(rw http.ResponseWriter, req *http.Request) {
3234
// Restrict only to instances where the browser is looking for an HTML file
33-
if !strings.Contains(req.Header.Get("Accept"), "text/html") {
34-
rw.WriteHeader(http.StatusNotFound)
35-
fmt.Fprint(rw, "404 not found")
35+
if !acceptsHTML(req) {
36+
rw.WriteHeader(http.StatusNotAcceptable)
37+
fmt.Fprint(rw, "not acceptable: only text/html is available")
3638

3739
return
3840
}
@@ -94,6 +96,46 @@ func serveIndexHTML(devMode bool, manifest map[string]any, pathPrefix string, fi
9496
}
9597
}
9698

99+
func acceptsHTML(req *http.Request) bool {
100+
accept := strings.TrimSpace(req.Header.Get("Accept"))
101+
if accept == "" {
102+
return true
103+
}
104+
105+
for part := range strings.SplitSeq(accept, ",") {
106+
part = strings.TrimSpace(part)
107+
if part == "" {
108+
continue
109+
}
110+
111+
mediaType, params, err := mime.ParseMediaType(part)
112+
if err != nil {
113+
mediaType = strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
114+
params = nil
115+
}
116+
117+
quality := 1.0
118+
if params != nil {
119+
if qRaw, ok := params["q"]; ok {
120+
if parsed, err := strconv.ParseFloat(qRaw, 64); err == nil {
121+
quality = parsed
122+
}
123+
}
124+
}
125+
126+
if quality <= 0 {
127+
continue
128+
}
129+
130+
switch mediaType {
131+
case "text/html", "text/*", "*/*":
132+
return true
133+
}
134+
}
135+
136+
return false
137+
}
138+
97139
type spaResponseWriter struct {
98140
http.ResponseWriter
99141

spa_response_writer_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package riverui
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
"testing/fstest"
8+
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestServeIndexHTMLAcceptNegotiation(t *testing.T) {
13+
t.Parallel()
14+
15+
files := fstest.MapFS{
16+
"index.html": &fstest.MapFile{Data: []byte("<html>ok</html>")},
17+
}
18+
19+
handler := serveIndexHTML(false, map[string]any{}, "/riverui", http.FS(files))
20+
21+
tests := []struct {
22+
name string
23+
acceptHeader string
24+
setAccept bool
25+
wantStatus int
26+
}{
27+
{
28+
name: "AcceptHTML",
29+
acceptHeader: "text/html",
30+
setAccept: true,
31+
wantStatus: http.StatusOK,
32+
},
33+
{
34+
name: "AcceptWildcard",
35+
acceptHeader: "*/*",
36+
setAccept: true,
37+
wantStatus: http.StatusOK,
38+
},
39+
{
40+
name: "AcceptTextWildcard",
41+
acceptHeader: "text/*",
42+
setAccept: true,
43+
wantStatus: http.StatusOK,
44+
},
45+
{
46+
name: "AcceptMissing",
47+
setAccept: false,
48+
wantStatus: http.StatusOK,
49+
},
50+
{
51+
name: "AcceptJSON",
52+
acceptHeader: "application/json",
53+
setAccept: true,
54+
wantStatus: http.StatusNotAcceptable,
55+
},
56+
}
57+
58+
for _, tt := range tests {
59+
t.Run(tt.name, func(t *testing.T) {
60+
t.Parallel()
61+
62+
req := httptest.NewRequest(http.MethodGet, "/", nil)
63+
if tt.setAccept {
64+
req.Header.Set("Accept", tt.acceptHeader)
65+
}
66+
67+
recorder := httptest.NewRecorder()
68+
handler.ServeHTTP(recorder, req)
69+
70+
require.Equal(t, tt.wantStatus, recorder.Result().StatusCode)
71+
})
72+
}
73+
}

0 commit comments

Comments
 (0)