Files
DriverVault/Web App/server/main.go
T
tajniak81andClaude Opus 5 f08849e50c Docker: a build context that isn't 3.6GB, and health you can see
The all-in-one image builds from the project root, and Docker only reads
.dockerignore from the context root — so the ones under "API Server" and
"Web App" never applied to it and every AIO build shipped the whole tree,
"Phone App/build" included. A root .dockerignore allow-lists the paths that
build actually copies.

The dev split stack passed neither PB_BOOTSTRAP nor the SUPERADMIN vars, so
it created the schema and then no user to log in with. It passes them now,
and .env.example says so.

WEBAPP_URL was never set anywhere, leaving the panel status page probing
localhost:8090 — itself — and always reporting the Web App as down. Each
compose file now points it at wherever the Web App really is, and the BFF
grew a real /healthz instead of letting the SPA fallback answer probes with
index.html and look healthy no matter what.

In the AIO, PocketBase and the API Server drop to an unprivileged user;
only nginx stays root to bind :80. The entrypoint takes ownership of the
two volumes first, so data written by the old root-only image stays
writable. All three images carry a HEALTHCHECK, every compose file declares
one too (so depends_on still gates against an older pulled image), and
web-app waits for the API Server to be serving rather than merely started.

Also: pinned alpine/golang/node and PocketBase 0.39.11, so a rebuild months
from now produces the same image; nginx forwards WebSocket upgrades instead
of stripping them, with the map in http.d where Alpine actually reads it;
and a .gitattributes keeps entrypoint.sh on LF, because a CRLF shebang from
a Windows clone fails at container start with "no such file or directory".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 17:07:52 +02:00

124 lines
3.3 KiB
Go

// Command webapp is the Web App backend-for-frontend. It serves the embedded
// Vue single-page app and reverse-proxies /api/* to the API Server, so the
// browser only ever talks to this server (same-origin) and all data access
// still flows through the API Server.
package main
import (
"embed"
"io/fs"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strings"
"time"
)
//go:embed all:dist
var distFS embed.FS
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func main() {
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
log.SetPrefix("[web] ")
loadDotEnv(".env")
addr := getenv("WEB_ADDR", ":8090")
apiBase := strings.TrimRight(getenv("API_BASE", "http://localhost:8080"), "/")
apiURL, err := url.Parse(apiBase)
if err != nil {
log.Fatalf("invalid API_BASE %q: %v", apiBase, err)
}
// Reverse proxy: /api/* -> API Server (path preserved).
proxy := httputil.NewSingleHostReverseProxy(apiURL)
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) {
log.Printf("proxy error for %s: %v", r.URL.Path, e)
http.Error(w, `{"error":"api server unavailable"}`, http.StatusBadGateway)
}
// Embedded SPA file server.
sub, err := fs.Sub(distFS, "dist")
if err != nil {
log.Fatalf("embed dist: %v", err)
}
spa := http.FileServer(http.FS(sub))
mux := http.NewServeMux()
mux.Handle("/api/", proxy)
// Liveness probe. The API Server polls this for the panel status page (see
// WEBAPP_URL), and container healthchecks use it. It must be a real route:
// without one the SPA catch-all below would answer with index.html, which
// looks healthy even when the proxy target is misconfigured.
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Serve static assets when they exist; otherwise fall back to index.html
// so client-side routing works on deep links.
if r.URL.Path != "/" {
if f, err := sub.Open(strings.TrimPrefix(r.URL.Path, "/")); err == nil {
f.Close()
spa.ServeHTTP(w, r)
return
}
}
r2 := r.Clone(r.Context())
r2.URL.Path = "/"
spa.ServeHTTP(w, r2)
})
srv := &http.Server{
Addr: addr,
Handler: logRequests(mux),
ReadHeaderTimeout: 10 * time.Second,
}
log.Printf("listening on %s (proxying /api -> %s)", addr, apiBase)
if err := srv.ListenAndServe(); err != nil {
log.Fatalf("server error: %v", err)
}
}
func logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
})
}
// loadDotEnv loads KEY=VALUE pairs from a .env file if present.
func loadDotEnv(path string) {
data, err := os.ReadFile(path)
if err != nil {
return
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
k = strings.TrimSpace(k)
v = strings.Trim(strings.TrimSpace(v), `"'`)
if _, exists := os.LookupEnv(k); !exists {
_ = os.Setenv(k, v)
}
}
}