diff --git a/DECISIONS.md b/DECISIONS.md index cf17045..0a50997 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -78,3 +78,4 @@ - 2026-02-28 m+git@andri.dk — Transparent HTTPS portal handler now passes `proxy.api` to `portalHandler`. Without it, any `/api/*` request on the transparent-mode portal would nil-pointer panic because the `api` field was zero-valued. - 2026-02-28 m+git@andri.dk — Transparent HTTP forwarding now handles WebSocket upgrades. `forwardHTTPUpgrade` mirrors `proxyHandler.handleHTTPUpgrade` — re-adds hop-by-hop upgrade headers, hijacks both sides on 101, and does bidirectional copy. Without this, WebSocket connections through the transparent HTTP proxy would fail with a `RoundTrip` error. - 2026-02-28 m+git@andri.dk — Fixed `singleConnListener` race condition. The old implementation returned an error immediately from the second `Accept`, causing `http.Server.Serve` to return before the in-flight request handler finished writing the response. The fix wraps the connection in `notifyCloseConn` which signals a channel on close; the second `Accept` blocks on that channel so `Serve` doesn't exit prematurely. Also added `IdleTimeout: 5s` to the portal's `http.Server` so connections don't block forever after the last response. +- 2026-02-28 m+git@andri.dk — Renamed all portal HTML files to `.gohtml` and converted them to Go templates. The nav bar (14 lines duplicated across 5 files) is now a shared template fragment in `static/nav.gohtml` included via `{{ template "nav" }}`. Pages are parsed with `parsePageTemplate()` which combines the nav fragment with each page template. The `.gohtml` extension stops HTML language servers from flagging Go template syntax as errors. Extracted `authGate()`, `apiPatch()`, and `createToggle()` into `shared.js` to deduplicate auth gating (5 identical onAuth/onUnauth pairs), PATCH wrappers (3 identical), and toggle DOM creation (3 identical). diff --git a/portal.go b/portal.go index 823a913..ba0bf64 100644 --- a/portal.go +++ b/portal.go @@ -13,19 +13,22 @@ import ( "ublproxy/internal/mobileconfig" ) -//go:embed static/portal.html +//go:embed static/nav.gohtml +var navHTML string + +//go:embed static/portal.gohtml var portalHTML string -//go:embed static/rules.html +//go:embed static/rules.gohtml var rulesHTML string -//go:embed static/subscriptions.html +//go:embed static/subscriptions.gohtml var subscriptionsHTML string -//go:embed static/activity.html +//go:embed static/activity.gohtml var activityHTML string -//go:embed static/users.html +//go:embed static/users.gohtml var usersHTML string //go:embed static/shared.css @@ -34,10 +37,24 @@ var sharedCSS string //go:embed static/shared.js var sharedJS string -//go:embed static/setup.html +//go:embed static/setup.gohtml var setupHTML string -var setupTmpl = template.Must(template.New("setup").Parse(setupHTML)) +// parsePageTemplate parses an HTML page template together with the +// shared nav fragment so {{ template "nav" }} resolves in every page. +func parsePageTemplate(name, content string) *template.Template { + t := template.Must(template.New(name).Parse(navHTML)) + return template.Must(t.Parse(content)) +} + +var ( + portalTmpl = parsePageTemplate("portal", portalHTML) + rulesTmpl = parsePageTemplate("rules", rulesHTML) + subscriptionsTmpl = parsePageTemplate("subscriptions", subscriptionsHTML) + activityTmpl = parsePageTemplate("activity", activityHTML) + usersTmpl = parsePageTemplate("users", usersHTML) + setupTmpl = parsePageTemplate("setup", setupHTML) +) // staticFiles maps /static/* paths to their embedded content and MIME type. var staticFiles = map[string]struct { @@ -113,9 +130,7 @@ func (s *setupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } if r.URL.Path == "/" || r.URL.Path == "/setup" { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusOK) - setupTmpl.Execute(w, setupData{PortalURL: s.portalOrigin, HttpOrigin: s.httpOrigin}) + servePage(w, setupTmpl, setupData{PortalURL: s.portalOrigin, HttpOrigin: s.httpOrigin}) return } http.NotFound(w, r) @@ -288,36 +303,34 @@ func (p *proxyHandler) handleMobilePAC(w http.ResponseWriter, r *http.Request) { pacTmpl.Execute(w, pacData{ProxyDirective: "PROXY", ProxyHost: parsed.Host}) } -func serveHTML(w http.ResponseWriter, content string) { +func servePage(w http.ResponseWriter, tmpl *template.Template, data any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) - w.Write([]byte(content)) + tmpl.Execute(w, data) } func (p *proxyHandler) handlePortalIndex(w http.ResponseWriter, r *http.Request) { - serveHTML(w, portalHTML) + servePage(w, portalTmpl, nil) } func (p *proxyHandler) handlePortalRules(w http.ResponseWriter, r *http.Request) { - serveHTML(w, rulesHTML) + servePage(w, rulesTmpl, nil) } func (p *proxyHandler) handlePortalSubscriptions(w http.ResponseWriter, r *http.Request) { - serveHTML(w, subscriptionsHTML) + servePage(w, subscriptionsTmpl, nil) } func (p *proxyHandler) handlePortalActivity(w http.ResponseWriter, r *http.Request) { - serveHTML(w, activityHTML) + servePage(w, activityTmpl, nil) } func (p *proxyHandler) handlePortalUsers(w http.ResponseWriter, r *http.Request) { - serveHTML(w, usersHTML) + servePage(w, usersTmpl, nil) } func (p *proxyHandler) handleSetup(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusOK) - setupTmpl.Execute(w, setupData{PortalURL: p.portalOrigin, HttpOrigin: p.httpOrigin}) + servePage(w, setupTmpl, setupData{PortalURL: p.portalOrigin, HttpOrigin: p.httpOrigin}) } func (p *proxyHandler) handleMobileconfig(w http.ResponseWriter, r *http.Request) { diff --git a/static/activity.html b/static/activity.gohtml similarity index 77% rename from static/activity.html rename to static/activity.gohtml index 47c92f6..fd2f3be 100644 --- a/static/activity.html +++ b/static/activity.gohtml @@ -7,20 +7,7 @@ - +{{ template "nav" }}