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" }}
@@ -67,27 +54,6 @@
var refreshTimer = null;
var allEntries = [];
- function onAuth() {
- if (!U.isAdmin()) {
- U.hide(document.getElementById('auth-view'));
- U.hide(document.getElementById('unauth-view'));
- U.show(document.getElementById('forbidden-view'));
- return;
- }
- U.show(document.getElementById('auth-view'));
- U.hide(document.getElementById('unauth-view'));
- U.hide(document.getElementById('forbidden-view'));
- loadActivity();
- refreshTimer = setInterval(loadActivity, 5000);
- }
-
- function onUnauth() {
- U.hide(document.getElementById('auth-view'));
- U.hide(document.getElementById('forbidden-view'));
- U.show(document.getElementById('unauth-view'));
- if (refreshTimer) clearInterval(refreshTimer);
- }
-
async function loadActivity() {
try {
var resp = await fetch('/api/activity?limit=200', { headers: U.authHeaders() });
@@ -175,7 +141,13 @@
});
});
- U.checkSession(onAuth, onUnauth);
+ U.authGate(function() {
+ loadActivity();
+ refreshTimer = setInterval(loadActivity, 5000);
+ }, {
+ requireAdmin: true,
+ onUnauth: function() { if (refreshTimer) clearInterval(refreshTimer); }
+ });
})();
diff --git a/static/nav.gohtml b/static/nav.gohtml
new file mode 100644
index 0000000..3f2a447
--- /dev/null
+++ b/static/nav.gohtml
@@ -0,0 +1,16 @@
+{{ define "nav" }}
+
+{{ end }}
diff --git a/static/portal.html b/static/portal.gohtml
similarity index 79%
rename from static/portal.html
rename to static/portal.gohtml
index 861c90a..c8807a9 100644
--- a/static/portal.html
+++ b/static/portal.gohtml
@@ -7,20 +7,7 @@
-
+{{ template "nav" }}
@@ -96,21 +83,8 @@
'use strict';
var U = window.ublproxy;
- var authView = document.getElementById('auth-view');
- var unauthView = document.getElementById('unauth-view');
var authMsg = document.getElementById('auth-msg');
- function onAuth() {
- U.hide(unauthView);
- U.show(authView);
- loadStats();
- }
-
- function onUnauth() {
- U.show(unauthView);
- U.hide(authView);
- }
-
async function loadStats() {
try {
var [rulesResp, subsResp, statsResp] = await Promise.all([
@@ -135,15 +109,19 @@
} catch (_) {}
}
+ function initAuth() {
+ U.authGate(loadStats);
+ }
+
document.getElementById('btn-register').addEventListener('click', function() {
- U.register(authMsg, function() { U.checkSession(onAuth, onUnauth); });
+ U.register(authMsg, initAuth);
});
document.getElementById('btn-login').addEventListener('click', function() {
- U.login(authMsg, function() { U.checkSession(onAuth, onUnauth); });
+ U.login(authMsg, initAuth);
});
- U.checkSession(onAuth, onUnauth);
+ initAuth();
})();
diff --git a/static/rules.html b/static/rules.gohtml
similarity index 79%
rename from static/rules.html
rename to static/rules.gohtml
index d648762..5c0ff0e 100644
--- a/static/rules.html
+++ b/static/rules.gohtml
@@ -7,20 +7,7 @@
-
+{{ template "nav" }}
@@ -87,17 +74,6 @@
var allRules = [];
- function onAuth() {
- U.show(document.getElementById('auth-view'));
- U.hide(document.getElementById('unauth-view'));
- loadRules();
- }
-
- function onUnauth() {
- U.hide(document.getElementById('auth-view'));
- U.show(document.getElementById('unauth-view'));
- }
-
async function loadRules() {
try {
var resp = await fetch('/api/rules', { headers: U.authHeaders() });
@@ -133,10 +109,7 @@
var li = document.createElement('li');
li.className = 'item';
- var toggle = document.createElement('label');
- toggle.className = 'toggle';
- toggle.innerHTML = '
';
- toggle.querySelector('input').addEventListener('change', function() { toggleRule(r.id, this.checked); });
+ var toggle = U.createToggle(r.enabled, function(checked) { toggleRule(r.id, checked); });
var text = document.createElement('span');
text.className = 'item-text';
@@ -178,12 +151,8 @@
}
}
- async function toggleRule(id, enabled) {
- try {
- await fetch('/api/rules/' + id, {
- method: 'PATCH', headers: U.authHeaders(), body: JSON.stringify({ enabled: enabled })
- });
- } catch (_) {}
+ function toggleRule(id, enabled) {
+ U.apiPatch('/api/rules/' + id, { enabled: enabled });
}
async function deleteRule(id) {
@@ -197,7 +166,7 @@
ruleInput.addEventListener('keydown', function(e) { if (e.key === 'Enter') addRule(); });
searchInput.addEventListener('input', function() { renderRules(allRules); });
- U.checkSession(onAuth, onUnauth);
+ U.authGate(loadRules);
})();
diff --git a/static/setup.html b/static/setup.gohtml
similarity index 100%
rename from static/setup.html
rename to static/setup.gohtml
diff --git a/static/shared.js b/static/shared.js
index 9146140..d762ce4 100644
--- a/static/shared.js
+++ b/static/shared.js
@@ -180,6 +180,53 @@
}
}
+ // --- Auth gating ---
+ // Checks session and toggles #auth-view / #unauth-view / #forbidden-view.
+ // opts.requireAdmin: if true, shows #forbidden-view for non-admin users.
+ // opts.onUnauth: optional callback when user is not authenticated (e.g. clear intervals).
+ function authGate(onReady, opts) {
+ opts = opts || {};
+ checkSession(
+ function onAuth() {
+ if (opts.requireAdmin && !_isAdmin) {
+ hide(document.getElementById('auth-view'));
+ hide(document.getElementById('unauth-view'));
+ show(document.getElementById('forbidden-view'));
+ return;
+ }
+ show(document.getElementById('auth-view'));
+ hide(document.getElementById('unauth-view'));
+ var forbidden = document.getElementById('forbidden-view');
+ if (forbidden) hide(forbidden);
+ if (onReady) onReady();
+ },
+ function onUnauth() {
+ hide(document.getElementById('auth-view'));
+ var forbidden = document.getElementById('forbidden-view');
+ if (forbidden) hide(forbidden);
+ show(document.getElementById('unauth-view'));
+ if (opts.onUnauth) opts.onUnauth();
+ }
+ );
+ }
+
+ // --- API helpers ---
+ async function apiPatch(path, body) {
+ try {
+ await fetch(path, {
+ method: 'PATCH', headers: authHeaders(), body: JSON.stringify(body)
+ });
+ } catch (_) {}
+ }
+
+ function createToggle(checked, onChange) {
+ var toggle = document.createElement('label');
+ toggle.className = 'toggle';
+ toggle.innerHTML = '
';
+ toggle.querySelector('input').addEventListener('change', function() { onChange(this.checked); });
+ return toggle;
+ }
+
// --- Logout ---
async function logout() {
try {
@@ -203,10 +250,13 @@
showMsg: showMsg,
clearMsg: clearMsg,
checkSession: checkSession,
+ authGate: authGate,
isAdmin: isAdmin,
register: register,
login: login,
logout: logout,
+ apiPatch: apiPatch,
+ createToggle: createToggle,
initNav: initNav
};
diff --git a/static/subscriptions.html b/static/subscriptions.gohtml
similarity index 82%
rename from static/subscriptions.html
rename to static/subscriptions.gohtml
index 4b6da0c..481b924 100644
--- a/static/subscriptions.html
+++ b/static/subscriptions.gohtml
@@ -7,20 +7,7 @@
-
+{{ template "nav" }}
@@ -87,17 +74,6 @@
var subUrl = document.getElementById('sub-url');
var subName = document.getElementById('sub-name');
- function onAuth() {
- U.show(document.getElementById('auth-view'));
- U.hide(document.getElementById('unauth-view'));
- loadSubscriptions();
- }
-
- function onUnauth() {
- U.hide(document.getElementById('auth-view'));
- U.show(document.getElementById('unauth-view'));
- }
-
async function loadSubscriptions() {
try {
var resp = await fetch('/api/subscriptions', { headers: U.authHeaders() });
@@ -121,10 +97,7 @@
var li = document.createElement('li');
li.className = 'item';
- var toggle = document.createElement('label');
- toggle.className = 'toggle';
- toggle.innerHTML = '
';
- toggle.querySelector('input').addEventListener('change', function() { toggleSubscription(s.id, this.checked); });
+ var toggle = U.createToggle(s.enabled, function(checked) { toggleSubscription(s.id, checked); });
var info = document.createElement('div');
info.className = 'item-info';
@@ -170,12 +143,8 @@
}
}
- async function toggleSubscription(id, enabled) {
- try {
- await fetch('/api/subscriptions/' + id, {
- method: 'PATCH', headers: U.authHeaders(), body: JSON.stringify({ enabled: enabled })
- });
- } catch (_) {}
+ function toggleSubscription(id, enabled) {
+ U.apiPatch('/api/subscriptions/' + id, { enabled: enabled });
}
async function deleteSubscription(id) {
@@ -217,7 +186,7 @@
});
});
- U.checkSession(onAuth, onUnauth);
+ U.authGate(loadSubscriptions);
})();
diff --git a/static/users.html b/static/users.gohtml
similarity index 75%
rename from static/users.html
rename to static/users.gohtml
index 85da52e..f2e21da 100644
--- a/static/users.html
+++ b/static/users.gohtml
@@ -7,20 +7,7 @@
-
+{{ template "nav" }}
@@ -58,25 +45,6 @@
var usersEmpty = document.getElementById('users-empty');
var usersCount = document.getElementById('users-count');
- function onAuth() {
- if (!U.isAdmin()) {
- U.hide(document.getElementById('auth-view'));
- U.hide(document.getElementById('unauth-view'));
- U.show(document.getElementById('forbidden-view'));
- return;
- }
- U.show(document.getElementById('auth-view'));
- U.hide(document.getElementById('unauth-view'));
- U.hide(document.getElementById('forbidden-view'));
- loadUsers();
- }
-
- function onUnauth() {
- U.hide(document.getElementById('auth-view'));
- U.hide(document.getElementById('forbidden-view'));
- U.show(document.getElementById('unauth-view'));
- }
-
async function loadUsers() {
try {
var resp = await fetch('/api/users', { headers: U.authHeaders() });
@@ -158,7 +126,7 @@
} catch (_) {}
}
- U.checkSession(onAuth, onUnauth);
+ U.authGate(loadUsers, { requireAdmin: true });
})();
diff --git a/transparent.go b/transparent.go
index 8336efd..c61d8d2 100644
--- a/transparent.go
+++ b/transparent.go
@@ -273,9 +273,7 @@ func (h *transparentHTTPHandler) servePortal(w http.ResponseWriter, r *http.Requ
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{
+ servePage(w, setupTmpl, setupData{
PortalURL: h.proxy.portalOrigin,
HttpOrigin: h.proxy.httpOrigin,
Transparent: true,