static.go (3342B)
1 package internal 2 3 import ( 4 "bytes" 5 "crypto/sha256" 6 "embed" 7 "fmt" 8 "io/fs" 9 "net/http" 10 "path" 11 "strings" 12 ) 13 14 //go:embed web/* 15 var webAssets embed.FS 16 17 // StaticHandler returns an http.Handler serving the embedded web UI files only. 18 // Unknown paths return 404 (no SPA fallback). 19 func StaticHandler() (http.Handler, error) { return StaticHandlerWithConfig(HandlerConfig{}) } 20 21 // StaticHandlerWithConfig serves the embedded UI, injecting runtime config. 22 func StaticHandlerWithConfig(cfg HandlerConfig) (http.Handler, error) { 23 sub, err := fs.Sub(webAssets, "web") 24 if err != nil { 25 return nil, err 26 } 27 fsys := http.FS(sub) 28 fileServer := http.FileServer(fsys) 29 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 30 p := strings.TrimPrefix(r.URL.Path, "/") 31 // Root should serve the login page (index) 32 if p == "" { 33 if html, err := RenderPage("login.html", cfg); err == nil { 34 html = applyCacheBust(html) 35 w.Header().Set("Content-Type", "text/html; charset=utf-8") 36 _, _ = w.Write(html) 37 return 38 } 39 } 40 // Try to stat the requested file 41 if f, err := fs.Stat(sub, path.Clean(p)); err == nil && !f.IsDir() { 42 fileServer.ServeHTTP(w, r) 43 return 44 } 45 // Unknown file: 404 46 http.NotFound(w, r) 47 }), nil 48 } 49 50 // RenderPage reads an embedded HTML page under internal/web and injects the 51 // shared header and optional runtime config. 52 func RenderPage(name string, cfg HandlerConfig) ([]byte, error) { 53 sub, err := fs.Sub(webAssets, "web") 54 if err != nil { 55 return nil, err 56 } 57 pageBytes, err := fs.ReadFile(sub, name) 58 if err != nil { 59 return nil, err 60 } 61 if hb, err := fs.ReadFile(sub, "header.html"); err == nil { 62 if bytes.Contains(pageBytes, []byte("{{HEADER}}")) { 63 pageBytes = bytes.ReplaceAll(pageBytes, []byte("{{HEADER}}"), hb) 64 } 65 } 66 if cfg.DisableSignup { 67 marker := []byte("<script src=\"/app.js\"></script>") 68 if i := bytes.Index(pageBytes, marker); i >= 0 { 69 inject := []byte("<script>window.MARKY_CFG={disableSignup:true}</script>\n") 70 nb := make([]byte, 0, len(pageBytes)+len(inject)) 71 nb = append(nb, pageBytes[:i]...) 72 nb = append(nb, inject...) 73 nb = append(nb, pageBytes[i:]...) 74 pageBytes = nb 75 } 76 } 77 return pageBytes, nil 78 } 79 80 // applyCacheBust appends a short content hash to known asset URLs in HTML, 81 // e.g., /app.js?v=abcd1234. This helps CDNs like Cloudflare pick up changes 82 // without manual purges. 83 func applyCacheBust(html []byte) []byte { 84 sub, err := fs.Sub(webAssets, "web") 85 if err != nil { 86 return html 87 } 88 // Build fingerprint map for assets we serve locally 89 assets := []string{ 90 "app.js", 91 "styles.css", 92 "favicon.svg", 93 "apple-touch-icon.png", 94 "favicon-32x32.png", 95 "favicon-16x16.png", 96 "vendor/vue.global.prod.js", 97 "vendor/marked.min.js", 98 "vendor/purify.min.js", 99 } 100 bust := map[string]string{} 101 for _, p := range assets { 102 if b, err := fs.ReadFile(sub, p); err == nil { 103 sum := sha256.Sum256(b) 104 bust[p] = fmt.Sprintf("%x", sum)[:10] 105 } 106 } 107 // Replace src/href occurrences 108 out := html 109 for p, v := range bust { 110 // src="/path" 111 from := []byte("src=\"/" + p + "\"") 112 to := []byte("src=\"/" + p + "?v=" + v + "\"") 113 out = bytes.ReplaceAll(out, from, to) 114 // href="/path" 115 fromH := []byte("href=\"/" + p + "\"") 116 toH := []byte("href=\"/" + p + "?v=" + v + "\"") 117 out = bytes.ReplaceAll(out, fromH, toH) 118 } 119 return out 120 }