Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- 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)
- Serve UI HTML for wildcard or missing Accept headers and return 406 for explicit non-HTML requests. Fixes #485. [PR #493](https://github.com/riverqueue/riverui/pull/493).

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

Expand Down
190 changes: 151 additions & 39 deletions spa_response_writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ package riverui
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"io"
"mime"
"net/http"
"slices"
"strconv"
"strings"
"time"
)

func intercept404(handler, on404 http.Handler) http.Handler {
Expand All @@ -28,70 +31,179 @@ func intercept404(handler, on404 http.Handler) http.Handler {
}

func serveIndexHTML(devMode bool, manifest map[string]any, pathPrefix string, files http.FileSystem) http.HandlerFunc {
cachedIndex := indexTemplateResult{}
if !devMode {
cachedIndex = loadIndexTemplate(files)
}

return func(rw http.ResponseWriter, req *http.Request) {
// Restrict only to instances where the browser is looking for an HTML file
if !strings.Contains(req.Header.Get("Accept"), "text/html") {
rw.WriteHeader(http.StatusNotFound)
fmt.Fprint(rw, "404 not found")
if req.Method != http.MethodGet && req.Method != http.MethodHead {
rw.Header().Set("Allow", "GET, HEAD")
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}

addVaryHeader(rw.Header(), "Accept")

// Restrict only to instances where the browser is looking for an HTML file
if !acceptsHTML(req) {
http.Error(rw, "not acceptable: only text/html is available", http.StatusNotAcceptable)
return
}

rawIndex, err := files.Open("index.html")
if err != nil {
http.Error(rw, "could not open index.html", http.StatusInternalServerError)
indexTemplate := cachedIndex
if devMode {
indexTemplate = loadIndexTemplate(files)
}
if indexTemplate.err != nil {
http.Error(rw, indexTemplate.errMessage, http.StatusInternalServerError)
return
}

config := struct {
APIURL string `json:"apiUrl"` //nolint:tagliatelle
Base string `json:"base"`
}{
config := indexTemplateConfig{
APIURL: pathPrefix + "/api",
Base: pathPrefix,
}

templateData := map[string]any{
"Config": config,
"Dev": devMode,
"Manifest": manifest,
"Base": pathPrefix,
templateData := indexTemplateData{
Config: config,
Dev: devMode,
Manifest: manifest,
Base: pathPrefix,
}

fileInfo, err := rawIndex.Stat()
if err != nil {
http.Error(rw, "could not stat index.html", http.StatusInternalServerError)
var output bytes.Buffer
if err := indexTemplate.tmpl.Execute(&output, templateData); err != nil {
http.Error(rw, "could not execute index.html", http.StatusInternalServerError)
return
}

indexBuf, err := io.ReadAll(rawIndex)
if err != nil {
http.Error(rw, "could not read index.html", http.StatusInternalServerError)
return
indexReader := bytes.NewReader(output.Bytes())

rw.Header().Set("Content-Type", "text/html; charset=utf-8")
http.ServeContent(rw, req, indexTemplate.name, indexTemplate.modTime, indexReader)
}
}

func acceptsHTML(req *http.Request) bool {
acceptValues := req.Header.Values("Accept")
if len(acceptValues) == 0 {
return true
}

return slices.ContainsFunc(acceptValues, acceptsHTMLValue)
}

func acceptsHTMLValue(accept string) bool {
if strings.TrimSpace(accept) == "" {
return true
}

for part := range strings.SplitSeq(accept, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}

tmpl, err := template.New("index.html").Funcs(template.FuncMap{
"marshal": func(v any) template.JS {
a, _ := json.Marshal(v)
return template.JS(a) //nolint:gosec
},
}).Parse(string(indexBuf))
mediaType, params, err := mime.ParseMediaType(part)
if err != nil {
http.Error(rw, "could not parse index.html", http.StatusInternalServerError)
return
mediaType = strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
params = nil
}

var output bytes.Buffer
if err = tmpl.Execute(&output, templateData); err != nil {
http.Error(rw, "could not execute index.html", http.StatusInternalServerError)
return
quality := 1.0
if params != nil {
if qRaw, ok := params["q"]; ok {
if parsed, err := strconv.ParseFloat(qRaw, 64); err == nil {
quality = parsed
}
}
}

index := bytes.NewReader(output.Bytes())
if quality <= 0 {
continue
}

rw.Header().Set("Content-Type", "text/html; charset=utf-8")
http.ServeContent(rw, req, fileInfo.Name(), fileInfo.ModTime(), index)
switch mediaType {
case "text/html", "application/xhtml+xml", "text/*", "*/*":
return true
}
}

return false
}

func addVaryHeader(headers http.Header, value string) {
for _, existing := range headers.Values("Vary") {
for part := range strings.SplitSeq(existing, ",") {
if strings.EqualFold(strings.TrimSpace(part), value) {
return
}
}
}

headers.Add("Vary", value)
}

type indexTemplateConfig struct {
APIURL string `json:"apiUrl"` //nolint:tagliatelle
Base string `json:"base"`
}

type indexTemplateData struct {
Config indexTemplateConfig
Dev bool
Manifest map[string]any
Base string
}

type indexTemplateResult struct {
tmpl *template.Template
name string
modTime time.Time
err error
errMessage string
}

func loadIndexTemplate(files http.FileSystem) indexTemplateResult {
rawIndex, err := files.Open("index.html")
if err != nil {
return indexTemplateResult{err: err, errMessage: "could not open index.html"}
}
defer rawIndex.Close()

fileInfo, err := rawIndex.Stat()
if err != nil {
return indexTemplateResult{err: err, errMessage: "could not stat index.html"}
}

indexBuf, err := io.ReadAll(rawIndex)
if err != nil {
return indexTemplateResult{err: err, errMessage: "could not read index.html"}
}

tmpl, err := parseIndexTemplate(indexBuf)
if err != nil {
return indexTemplateResult{err: err, errMessage: "could not parse index.html"}
}

return indexTemplateResult{
tmpl: tmpl,
name: fileInfo.Name(),
modTime: fileInfo.ModTime(),
}
}

func parseIndexTemplate(indexBuf []byte) (*template.Template, error) {
return template.New("index.html").Funcs(template.FuncMap{
"marshal": func(v any) (template.JS, error) {
payload, err := json.Marshal(v)
if err != nil {
return "", err
}
return template.JS(payload), nil //nolint:gosec
},
}).Parse(string(indexBuf))
}

type spaResponseWriter struct {
Expand Down
Loading
Loading