~bigbes/lethe

ref: f1673181bc8cd0298403408f20740423a43b84a0 lethe/internal/server/web/embed.go -rw-r--r-- 5.3 KiB
f1673181 — Eugene Blikh collector: fix daemon drain and backfill start 24 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
// Package web embeds the compiled SPA assets (produced by `just web-build`)
// into the Go binary via go:embed so the server binary is self-contained.
// At build time `dist/` must be non-empty; the committed placeholder
// index.html satisfies this on a fresh clone. The HTTP handler applies an
// SPA fallback shim: any path that does not match a real file in the
// embedded tree is rewritten to index.html so the client-side router can
// handle it. Real static assets (hashed JS/CSS chunks) are served normally
// with their correct Content-Type; the fallback only fires on 404s.
package web

import (
	"bytes"
	"embed"
	"encoding/json"
	"fmt"
	"io/fs"
	"net/http"
)

//go:embed all:dist
var distFS embed.FS

// Config holds the values injected into the SPA's index.html as
// window.__LETHE_CONFIG__. It must not contain any tokens (IV4).
type Config struct {
	Issuer   string `json:"issuer"`
	ClientID string `json:"client_id"`
}

// DistFS returns the sub-filesystem rooted at "dist" within the embedded FS.
// Exported for tests that need to discover actual asset filenames.
func DistFS() (fs.FS, error) {
	return fs.Sub(distFS, "dist")
}

// Handler returns an http.Handler that serves the embedded SPA. Paths that
// exist in the embedded tree are served directly. Paths that do not exist
// fall back to index.html with HTTP 200 so the client-side router handles
// them. If index.html itself cannot be opened from the embedded FS, the
// handler returns 500 with a plain-text error.
//
// On responses that serve index.html (root path and SPA fallback paths), the
// handler injects <script>window.__LETHE_CONFIG__=…;</script> before the
// closing </head> tag so the SPA can read OIDC config without hard-coding it.
// Paths beginning with /assets/ bypass injection and are served as raw bytes.
//
// Routes beginning with /api/, /healthz, /readyz, or /metrics are NOT
// this handler's concern; they must be mounted before this handler in the
// router so they shadow the catch-all.
func Handler(cfg Config) http.Handler {
	sub, err := fs.Sub(distFS, "dist")
	if err != nil {
		// This should never happen for a hard-coded path; blow up loudly.
		panic(fmt.Sprintf("web: fs.Sub on embedded dist: %v", err))
	}
	fileServer := http.FileServer(http.FS(sub))
	return &spaHandler{fs: sub, fileServer: fileServer, cfg: cfg}
}

// spaHandler wraps a standard file server with a SPA fallback: if the
// requested path does not exist in the embedded FS, it serves index.html
// instead of a 404. It also injects window.__LETHE_CONFIG__ into index.html
// responses.
type spaHandler struct {
	fs         fs.FS
	fileServer http.Handler
	cfg        Config
}

func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// Strip the leading "/" to obtain the relative path within the FS.
	path := r.URL.Path
	if len(path) > 0 && path[0] == '/' {
		path = path[1:]
	}
	if path == "" {
		path = "index.html"
	}

	// Static assets bypass injection; serve directly via the file server.
	if len(r.URL.Path) >= 8 && r.URL.Path[:8] == "/assets/" {
		h.fileServer.ServeHTTP(w, r)
		return
	}

	// Try to open the file to see if it exists.
	f, err := h.fs.Open(path)
	if err != nil {
		// File not found — apply SPA fallback: serve index.html at status 200.
		h.serveIndex(w, r)
		return
	}

	// Stat to confirm it's a regular file (not a directory that would
	// trigger a redirect to path+"/").
	info, statErr := f.Stat()
	_ = f.Close()
	if statErr != nil || info.IsDir() {
		h.serveIndex(w, r)
		return
	}

	// The file exists — check if it is index.html.
	if path == "index.html" {
		h.serveIndex(w, r)
		return
	}

	// Non-index file — let the standard file server handle it with correct
	// Content-Type, ETag, and range support.
	h.fileServer.ServeHTTP(w, r)
}

// serveIndex reads index.html from the embedded FS, injects the config script
// before </head>, and writes the result with HTTP 200. It returns 500 (plain-
// text) if the embedded FS cannot open or read the file — this is a build
// invariant violation and must not be silently swallowed.
func (h *spaHandler) serveIndex(w http.ResponseWriter, _ *http.Request) {
	f, err := h.fs.Open("index.html")
	if err != nil {
		http.Error(w, "internal server error: embedded index.html missing", http.StatusInternalServerError)
		return
	}
	defer f.Close()

	var buf bytes.Buffer
	if _, err := buf.ReadFrom(f); err != nil {
		http.Error(w, "internal server error: read embedded index.html", http.StatusInternalServerError)
		return
	}

	cfgJSON, err := json.Marshal(h.cfg)
	if err != nil {
		// Config is a simple struct with string fields; Marshal must not fail.
		http.Error(w, "internal server error: marshal config", http.StatusInternalServerError)
		return
	}

	original := buf.Bytes()
	script := []byte("<script>window.__LETHE_CONFIG__=" + string(cfgJSON) + ";</script></head>")
	injected := bytes.Replace(original, []byte("</head>"), script, 1)
	if bytes.Equal(injected, original) {
		// </head> is required for config injection. Failing loud beats serving
		// uninjected HTML and letting the SPA render its "auth-config missing"
		// card with no server-side diagnostic.
		http.Error(w, "internal server error: index.html missing </head> for config injection", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write(injected)
}