ci / Go (lint, test, vuln) (push) Successful in 1m58s
ci / Development environment (compose) (push) Failing after 14s
ci / Protobuf (push) Successful in 6s
ci / Web (lint, test, audit, build) (push) Successful in 32s
ci / Shell and licence headers (push) Successful in 6s
ci / Release build (dry run) (push) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
101 lines
3.0 KiB
Go
101 lines
3.0 KiB
Go
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
|
|
|
|
// Package web embeds the built frontend (web/dist, produced by `npm run build`)
|
|
// into the server binary and serves it as a single-page application.
|
|
package web
|
|
|
|
import (
|
|
"embed"
|
|
"errors"
|
|
"io/fs"
|
|
"net/http"
|
|
"path"
|
|
"strings"
|
|
)
|
|
|
|
//go:embed all:dist
|
|
var embedded embed.FS
|
|
|
|
// Handler serves the embedded frontend.
|
|
func Handler() http.Handler {
|
|
sub, err := fs.Sub(embedded, "dist")
|
|
if err != nil {
|
|
// fs.Sub only fails for invalid path names; "dist" is constant.
|
|
panic(err)
|
|
}
|
|
return NewHandler(sub)
|
|
}
|
|
|
|
// NewHandler serves a built frontend from fsys:
|
|
// - existing files are served directly; hashed files under assets/ are
|
|
// cacheable forever, everything else must be revalidated;
|
|
// - paths without a file extension fall back to index.html so that the
|
|
// client-side router can handle them;
|
|
// - if no index.html exists (Go-only development build), a notice page
|
|
// explains how to build the UI instead of failing.
|
|
func NewHandler(fsys fs.FS) http.Handler {
|
|
files := http.FileServerFS(fsys)
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
|
|
if name == "" {
|
|
name = "index.html"
|
|
}
|
|
|
|
if info, err := fs.Stat(fsys, name); err == nil && !info.IsDir() {
|
|
if strings.HasPrefix(name, "assets/") {
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
} else {
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
}
|
|
if name == "index.html" {
|
|
serveIndex(w, r, fsys)
|
|
return
|
|
}
|
|
files.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// index.html itself may be missing (UI not built): serveIndex then
|
|
// shows the notice page instead of a bare 404.
|
|
if name != "index.html" && path.Ext(name) != "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
serveIndex(w, r, fsys)
|
|
})
|
|
}
|
|
|
|
// serveIndex writes index.html directly; http.FileServer would redirect
|
|
// requests for /index.html to /.
|
|
func serveIndex(w http.ResponseWriter, r *http.Request, fsys fs.FS) {
|
|
data, err := fs.ReadFile(fsys, "index.html")
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
_, _ = w.Write([]byte(notBuiltPage))
|
|
return
|
|
}
|
|
if err != nil {
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if r.Method == http.MethodHead {
|
|
return
|
|
}
|
|
_, _ = w.Write(data)
|
|
}
|
|
|
|
const notBuiltPage = `<!doctype html>
|
|
<html lang="de">
|
|
<head><meta charset="utf-8"><title>Weboberfläche nicht gebaut</title></head>
|
|
<body>
|
|
<h1>Weboberfläche nicht gebaut</h1>
|
|
<p>Dieses Server-Binary enthält keine Weboberfläche. Bauen Sie sie mit <code>make web</code>
|
|
und anschließend den Server mit <code>make build</code>.</p>
|
|
<p>Die Endpunkte <code>/healthz</code> und <code>/readyz</code> sind unabhängig davon verfügbar.</p>
|
|
</body>
|
|
</html>
|
|
`
|