[READ-ONLY] Mirror of https://github.com/andrioid/ublproxy. andrioid.github.io/ublproxy/
adblock adblock-plus-list adblocker privacy-tools proxy-server self-hosted
0

Configure Feed

Select the types of activity you want to include in your feed.

Deduplicate portal frontend: shared nav template, extract JS utilities

Rename all portal HTML files to .gohtml so HTML language servers don't
flag Go template syntax as errors. Extract the nav bar (duplicated
across 5 files) into a shared nav.gohtml template fragment. Add
authGate(), apiPatch(), and createToggle() utilities to shared.js to
replace duplicated auth gating, PATCH wrappers, and toggle DOM builders.

Net reduction: 82 lines (-195, +113).

+129 -195
+1
DECISIONS.md
··· 78 78 - 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. 79 79 - 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. 80 80 - 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. 81 + - 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).
+33 -20
portal.go
··· 13 13 "ublproxy/internal/mobileconfig" 14 14 ) 15 15 16 - //go:embed static/portal.html 16 + //go:embed static/nav.gohtml 17 + var navHTML string 18 + 19 + //go:embed static/portal.gohtml 17 20 var portalHTML string 18 21 19 - //go:embed static/rules.html 22 + //go:embed static/rules.gohtml 20 23 var rulesHTML string 21 24 22 - //go:embed static/subscriptions.html 25 + //go:embed static/subscriptions.gohtml 23 26 var subscriptionsHTML string 24 27 25 - //go:embed static/activity.html 28 + //go:embed static/activity.gohtml 26 29 var activityHTML string 27 30 28 - //go:embed static/users.html 31 + //go:embed static/users.gohtml 29 32 var usersHTML string 30 33 31 34 //go:embed static/shared.css ··· 34 37 //go:embed static/shared.js 35 38 var sharedJS string 36 39 37 - //go:embed static/setup.html 40 + //go:embed static/setup.gohtml 38 41 var setupHTML string 39 42 40 - var setupTmpl = template.Must(template.New("setup").Parse(setupHTML)) 43 + // parsePageTemplate parses an HTML page template together with the 44 + // shared nav fragment so {{ template "nav" }} resolves in every page. 45 + func parsePageTemplate(name, content string) *template.Template { 46 + t := template.Must(template.New(name).Parse(navHTML)) 47 + return template.Must(t.Parse(content)) 48 + } 49 + 50 + var ( 51 + portalTmpl = parsePageTemplate("portal", portalHTML) 52 + rulesTmpl = parsePageTemplate("rules", rulesHTML) 53 + subscriptionsTmpl = parsePageTemplate("subscriptions", subscriptionsHTML) 54 + activityTmpl = parsePageTemplate("activity", activityHTML) 55 + usersTmpl = parsePageTemplate("users", usersHTML) 56 + setupTmpl = parsePageTemplate("setup", setupHTML) 57 + ) 41 58 42 59 // staticFiles maps /static/* paths to their embedded content and MIME type. 43 60 var staticFiles = map[string]struct { ··· 113 130 return 114 131 } 115 132 if r.URL.Path == "/" || r.URL.Path == "/setup" { 116 - w.Header().Set("Content-Type", "text/html; charset=utf-8") 117 - w.WriteHeader(http.StatusOK) 118 - setupTmpl.Execute(w, setupData{PortalURL: s.portalOrigin, HttpOrigin: s.httpOrigin}) 133 + servePage(w, setupTmpl, setupData{PortalURL: s.portalOrigin, HttpOrigin: s.httpOrigin}) 119 134 return 120 135 } 121 136 http.NotFound(w, r) ··· 288 303 pacTmpl.Execute(w, pacData{ProxyDirective: "PROXY", ProxyHost: parsed.Host}) 289 304 } 290 305 291 - func serveHTML(w http.ResponseWriter, content string) { 306 + func servePage(w http.ResponseWriter, tmpl *template.Template, data any) { 292 307 w.Header().Set("Content-Type", "text/html; charset=utf-8") 293 308 w.WriteHeader(http.StatusOK) 294 - w.Write([]byte(content)) 309 + tmpl.Execute(w, data) 295 310 } 296 311 297 312 func (p *proxyHandler) handlePortalIndex(w http.ResponseWriter, r *http.Request) { 298 - serveHTML(w, portalHTML) 313 + servePage(w, portalTmpl, nil) 299 314 } 300 315 301 316 func (p *proxyHandler) handlePortalRules(w http.ResponseWriter, r *http.Request) { 302 - serveHTML(w, rulesHTML) 317 + servePage(w, rulesTmpl, nil) 303 318 } 304 319 305 320 func (p *proxyHandler) handlePortalSubscriptions(w http.ResponseWriter, r *http.Request) { 306 - serveHTML(w, subscriptionsHTML) 321 + servePage(w, subscriptionsTmpl, nil) 307 322 } 308 323 309 324 func (p *proxyHandler) handlePortalActivity(w http.ResponseWriter, r *http.Request) { 310 - serveHTML(w, activityHTML) 325 + servePage(w, activityTmpl, nil) 311 326 } 312 327 313 328 func (p *proxyHandler) handlePortalUsers(w http.ResponseWriter, r *http.Request) { 314 - serveHTML(w, usersHTML) 329 + servePage(w, usersTmpl, nil) 315 330 } 316 331 317 332 func (p *proxyHandler) handleSetup(w http.ResponseWriter, r *http.Request) { 318 - w.Header().Set("Content-Type", "text/html; charset=utf-8") 319 - w.WriteHeader(http.StatusOK) 320 - setupTmpl.Execute(w, setupData{PortalURL: p.portalOrigin, HttpOrigin: p.httpOrigin}) 333 + servePage(w, setupTmpl, setupData{PortalURL: p.portalOrigin, HttpOrigin: p.httpOrigin}) 321 334 } 322 335 323 336 func (p *proxyHandler) handleMobileconfig(w http.ResponseWriter, r *http.Request) {
+8 -36
static/activity.html static/activity.gohtml
··· 7 7 <link rel="stylesheet" href="/static/shared.css"> 8 8 </head> 9 9 <body> 10 - <nav class="nav"> 11 - <a href="/" class="nav-brand">ublproxy</a> 12 - <ul class="nav-links"> 13 - <li><a href="/" class="nav-link">Dashboard</a></li> 14 - <li><a href="/rules" class="nav-link" data-auth>Rules</a></li> 15 - <li><a href="/subscriptions" class="nav-link" data-auth>Subscriptions</a></li> 16 - <li><a href="/activity" class="nav-link" data-admin>Activity</a></li> 17 - <li><a href="/users" class="nav-link" data-admin>Users</a></li> 18 - </ul> 19 - <div class="nav-right"> 20 - <span class="nav-status" id="nav-status">Checking&hellip;</span> 21 - <a href="#" class="btn btn-ghost btn-sm hidden" id="nav-logout">Sign out</a> 22 - </div> 23 - </nav> 10 + {{ template "nav" }} 24 11 25 12 <main class="main"> 26 13 <div id="unauth-view" class="hidden"> ··· 66 53 var activeFilter = 'all'; 67 54 var refreshTimer = null; 68 55 var allEntries = []; 69 - 70 - function onAuth() { 71 - if (!U.isAdmin()) { 72 - U.hide(document.getElementById('auth-view')); 73 - U.hide(document.getElementById('unauth-view')); 74 - U.show(document.getElementById('forbidden-view')); 75 - return; 76 - } 77 - U.show(document.getElementById('auth-view')); 78 - U.hide(document.getElementById('unauth-view')); 79 - U.hide(document.getElementById('forbidden-view')); 80 - loadActivity(); 81 - refreshTimer = setInterval(loadActivity, 5000); 82 - } 83 - 84 - function onUnauth() { 85 - U.hide(document.getElementById('auth-view')); 86 - U.hide(document.getElementById('forbidden-view')); 87 - U.show(document.getElementById('unauth-view')); 88 - if (refreshTimer) clearInterval(refreshTimer); 89 - } 90 56 91 57 async function loadActivity() { 92 58 try { ··· 175 141 }); 176 142 }); 177 143 178 - U.checkSession(onAuth, onUnauth); 144 + U.authGate(function() { 145 + loadActivity(); 146 + refreshTimer = setInterval(loadActivity, 5000); 147 + }, { 148 + requireAdmin: true, 149 + onUnauth: function() { if (refreshTimer) clearInterval(refreshTimer); } 150 + }); 179 151 })(); 180 152 </script> 181 153 </body>
+16
static/nav.gohtml
··· 1 + {{ define "nav" }} 2 + <nav class="nav"> 3 + <a href="/" class="nav-brand">ublproxy</a> 4 + <ul class="nav-links"> 5 + <li><a href="/" class="nav-link">Dashboard</a></li> 6 + <li><a href="/rules" class="nav-link" data-auth>Rules</a></li> 7 + <li><a href="/subscriptions" class="nav-link" data-auth>Subscriptions</a></li> 8 + <li><a href="/activity" class="nav-link" data-admin>Activity</a></li> 9 + <li><a href="/users" class="nav-link" data-admin>Users</a></li> 10 + </ul> 11 + <div class="nav-right"> 12 + <span class="nav-status" id="nav-status">Checking&hellip;</span> 13 + <a href="#" class="btn btn-ghost btn-sm hidden" id="nav-logout">Sign out</a> 14 + </div> 15 + </nav> 16 + {{ end }}
+8 -30
static/portal.html static/portal.gohtml
··· 7 7 <link rel="stylesheet" href="/static/shared.css"> 8 8 </head> 9 9 <body> 10 - <nav class="nav"> 11 - <a href="/" class="nav-brand">ublproxy</a> 12 - <ul class="nav-links"> 13 - <li><a href="/" class="nav-link">Dashboard</a></li> 14 - <li><a href="/rules" class="nav-link" data-auth>Rules</a></li> 15 - <li><a href="/subscriptions" class="nav-link" data-auth>Subscriptions</a></li> 16 - <li><a href="/activity" class="nav-link" data-admin>Activity</a></li> 17 - <li><a href="/users" class="nav-link" data-admin>Users</a></li> 18 - </ul> 19 - <div class="nav-right"> 20 - <span class="nav-status" id="nav-status">Checking&hellip;</span> 21 - <a href="#" class="btn btn-ghost btn-sm hidden" id="nav-logout">Sign out</a> 22 - </div> 23 - </nav> 10 + {{ template "nav" }} 24 11 25 12 <main class="main"> 26 13 <!-- Unauthenticated: show auth card --> ··· 96 83 'use strict'; 97 84 var U = window.ublproxy; 98 85 99 - var authView = document.getElementById('auth-view'); 100 - var unauthView = document.getElementById('unauth-view'); 101 86 var authMsg = document.getElementById('auth-msg'); 102 - 103 - function onAuth() { 104 - U.hide(unauthView); 105 - U.show(authView); 106 - loadStats(); 107 - } 108 - 109 - function onUnauth() { 110 - U.show(unauthView); 111 - U.hide(authView); 112 - } 113 87 114 88 async function loadStats() { 115 89 try { ··· 135 109 } catch (_) {} 136 110 } 137 111 112 + function initAuth() { 113 + U.authGate(loadStats); 114 + } 115 + 138 116 document.getElementById('btn-register').addEventListener('click', function() { 139 - U.register(authMsg, function() { U.checkSession(onAuth, onUnauth); }); 117 + U.register(authMsg, initAuth); 140 118 }); 141 119 142 120 document.getElementById('btn-login').addEventListener('click', function() { 143 - U.login(authMsg, function() { U.checkSession(onAuth, onUnauth); }); 121 + U.login(authMsg, initAuth); 144 122 }); 145 123 146 - U.checkSession(onAuth, onUnauth); 124 + initAuth(); 147 125 })(); 148 126 </script> 149 127 </body>
+5 -36
static/rules.html static/rules.gohtml
··· 7 7 <link rel="stylesheet" href="/static/shared.css"> 8 8 </head> 9 9 <body> 10 - <nav class="nav"> 11 - <a href="/" class="nav-brand">ublproxy</a> 12 - <ul class="nav-links"> 13 - <li><a href="/" class="nav-link">Dashboard</a></li> 14 - <li><a href="/rules" class="nav-link" data-auth>Rules</a></li> 15 - <li><a href="/subscriptions" class="nav-link" data-auth>Subscriptions</a></li> 16 - <li><a href="/activity" class="nav-link" data-admin>Activity</a></li> 17 - <li><a href="/users" class="nav-link" data-admin>Users</a></li> 18 - </ul> 19 - <div class="nav-right"> 20 - <span class="nav-status" id="nav-status">Checking&hellip;</span> 21 - <a href="#" class="btn btn-ghost btn-sm hidden" id="nav-logout">Sign out</a> 22 - </div> 23 - </nav> 10 + {{ template "nav" }} 24 11 25 12 <main class="main"> 26 13 <div id="unauth-view" class="hidden"> ··· 87 74 88 75 var allRules = []; 89 76 90 - function onAuth() { 91 - U.show(document.getElementById('auth-view')); 92 - U.hide(document.getElementById('unauth-view')); 93 - loadRules(); 94 - } 95 - 96 - function onUnauth() { 97 - U.hide(document.getElementById('auth-view')); 98 - U.show(document.getElementById('unauth-view')); 99 - } 100 - 101 77 async function loadRules() { 102 78 try { 103 79 var resp = await fetch('/api/rules', { headers: U.authHeaders() }); ··· 133 109 var li = document.createElement('li'); 134 110 li.className = 'item'; 135 111 136 - var toggle = document.createElement('label'); 137 - toggle.className = 'toggle'; 138 - toggle.innerHTML = '<input type="checkbox"' + (r.enabled ? ' checked' : '') + '><span class="toggle-slider"></span>'; 139 - toggle.querySelector('input').addEventListener('change', function() { toggleRule(r.id, this.checked); }); 112 + var toggle = U.createToggle(r.enabled, function(checked) { toggleRule(r.id, checked); }); 140 113 141 114 var text = document.createElement('span'); 142 115 text.className = 'item-text'; ··· 178 151 } 179 152 } 180 153 181 - async function toggleRule(id, enabled) { 182 - try { 183 - await fetch('/api/rules/' + id, { 184 - method: 'PATCH', headers: U.authHeaders(), body: JSON.stringify({ enabled: enabled }) 185 - }); 186 - } catch (_) {} 154 + function toggleRule(id, enabled) { 155 + U.apiPatch('/api/rules/' + id, { enabled: enabled }); 187 156 } 188 157 189 158 async function deleteRule(id) { ··· 197 166 ruleInput.addEventListener('keydown', function(e) { if (e.key === 'Enter') addRule(); }); 198 167 searchInput.addEventListener('input', function() { renderRules(allRules); }); 199 168 200 - U.checkSession(onAuth, onUnauth); 169 + U.authGate(loadRules); 201 170 })(); 202 171 </script> 203 172 </body>
static/setup.html static/setup.gohtml
+50
static/shared.js
··· 180 180 } 181 181 } 182 182 183 + // --- Auth gating --- 184 + // Checks session and toggles #auth-view / #unauth-view / #forbidden-view. 185 + // opts.requireAdmin: if true, shows #forbidden-view for non-admin users. 186 + // opts.onUnauth: optional callback when user is not authenticated (e.g. clear intervals). 187 + function authGate(onReady, opts) { 188 + opts = opts || {}; 189 + checkSession( 190 + function onAuth() { 191 + if (opts.requireAdmin && !_isAdmin) { 192 + hide(document.getElementById('auth-view')); 193 + hide(document.getElementById('unauth-view')); 194 + show(document.getElementById('forbidden-view')); 195 + return; 196 + } 197 + show(document.getElementById('auth-view')); 198 + hide(document.getElementById('unauth-view')); 199 + var forbidden = document.getElementById('forbidden-view'); 200 + if (forbidden) hide(forbidden); 201 + if (onReady) onReady(); 202 + }, 203 + function onUnauth() { 204 + hide(document.getElementById('auth-view')); 205 + var forbidden = document.getElementById('forbidden-view'); 206 + if (forbidden) hide(forbidden); 207 + show(document.getElementById('unauth-view')); 208 + if (opts.onUnauth) opts.onUnauth(); 209 + } 210 + ); 211 + } 212 + 213 + // --- API helpers --- 214 + async function apiPatch(path, body) { 215 + try { 216 + await fetch(path, { 217 + method: 'PATCH', headers: authHeaders(), body: JSON.stringify(body) 218 + }); 219 + } catch (_) {} 220 + } 221 + 222 + function createToggle(checked, onChange) { 223 + var toggle = document.createElement('label'); 224 + toggle.className = 'toggle'; 225 + toggle.innerHTML = '<input type="checkbox"' + (checked ? ' checked' : '') + '><span class="toggle-slider"></span>'; 226 + toggle.querySelector('input').addEventListener('change', function() { onChange(this.checked); }); 227 + return toggle; 228 + } 229 + 183 230 // --- Logout --- 184 231 async function logout() { 185 232 try { ··· 203 250 showMsg: showMsg, 204 251 clearMsg: clearMsg, 205 252 checkSession: checkSession, 253 + authGate: authGate, 206 254 isAdmin: isAdmin, 207 255 register: register, 208 256 login: login, 209 257 logout: logout, 258 + apiPatch: apiPatch, 259 + createToggle: createToggle, 210 260 initNav: initNav 211 261 }; 212 262
+5 -36
static/subscriptions.html static/subscriptions.gohtml
··· 7 7 <link rel="stylesheet" href="/static/shared.css"> 8 8 </head> 9 9 <body> 10 - <nav class="nav"> 11 - <a href="/" class="nav-brand">ublproxy</a> 12 - <ul class="nav-links"> 13 - <li><a href="/" class="nav-link">Dashboard</a></li> 14 - <li><a href="/rules" class="nav-link" data-auth>Rules</a></li> 15 - <li><a href="/subscriptions" class="nav-link" data-auth>Subscriptions</a></li> 16 - <li><a href="/activity" class="nav-link" data-admin>Activity</a></li> 17 - <li><a href="/users" class="nav-link" data-admin>Users</a></li> 18 - </ul> 19 - <div class="nav-right"> 20 - <span class="nav-status" id="nav-status">Checking&hellip;</span> 21 - <a href="#" class="btn btn-ghost btn-sm hidden" id="nav-logout">Sign out</a> 22 - </div> 23 - </nav> 10 + {{ template "nav" }} 24 11 25 12 <main class="main"> 26 13 <div id="unauth-view" class="hidden"> ··· 87 74 var subUrl = document.getElementById('sub-url'); 88 75 var subName = document.getElementById('sub-name'); 89 76 90 - function onAuth() { 91 - U.show(document.getElementById('auth-view')); 92 - U.hide(document.getElementById('unauth-view')); 93 - loadSubscriptions(); 94 - } 95 - 96 - function onUnauth() { 97 - U.hide(document.getElementById('auth-view')); 98 - U.show(document.getElementById('unauth-view')); 99 - } 100 - 101 77 async function loadSubscriptions() { 102 78 try { 103 79 var resp = await fetch('/api/subscriptions', { headers: U.authHeaders() }); ··· 121 97 var li = document.createElement('li'); 122 98 li.className = 'item'; 123 99 124 - var toggle = document.createElement('label'); 125 - toggle.className = 'toggle'; 126 - toggle.innerHTML = '<input type="checkbox"' + (s.enabled ? ' checked' : '') + '><span class="toggle-slider"></span>'; 127 - toggle.querySelector('input').addEventListener('change', function() { toggleSubscription(s.id, this.checked); }); 100 + var toggle = U.createToggle(s.enabled, function(checked) { toggleSubscription(s.id, checked); }); 128 101 129 102 var info = document.createElement('div'); 130 103 info.className = 'item-info'; ··· 170 143 } 171 144 } 172 145 173 - async function toggleSubscription(id, enabled) { 174 - try { 175 - await fetch('/api/subscriptions/' + id, { 176 - method: 'PATCH', headers: U.authHeaders(), body: JSON.stringify({ enabled: enabled }) 177 - }); 178 - } catch (_) {} 146 + function toggleSubscription(id, enabled) { 147 + U.apiPatch('/api/subscriptions/' + id, { enabled: enabled }); 179 148 } 180 149 181 150 async function deleteSubscription(id) { ··· 217 186 }); 218 187 }); 219 188 220 - U.checkSession(onAuth, onUnauth); 189 + U.authGate(loadSubscriptions); 221 190 })(); 222 191 </script> 223 192 </body>
+2 -34
static/users.html static/users.gohtml
··· 7 7 <link rel="stylesheet" href="/static/shared.css"> 8 8 </head> 9 9 <body> 10 - <nav class="nav"> 11 - <a href="/" class="nav-brand">ublproxy</a> 12 - <ul class="nav-links"> 13 - <li><a href="/" class="nav-link">Dashboard</a></li> 14 - <li><a href="/rules" class="nav-link" data-auth>Rules</a></li> 15 - <li><a href="/subscriptions" class="nav-link" data-auth>Subscriptions</a></li> 16 - <li><a href="/activity" class="nav-link" data-admin>Activity</a></li> 17 - <li><a href="/users" class="nav-link" data-admin>Users</a></li> 18 - </ul> 19 - <div class="nav-right"> 20 - <span class="nav-status" id="nav-status">Checking&hellip;</span> 21 - <a href="#" class="btn btn-ghost btn-sm hidden" id="nav-logout">Sign out</a> 22 - </div> 23 - </nav> 10 + {{ template "nav" }} 24 11 25 12 <main class="main"> 26 13 <div id="unauth-view" class="hidden"> ··· 57 44 var usersList = document.getElementById('users-list'); 58 45 var usersEmpty = document.getElementById('users-empty'); 59 46 var usersCount = document.getElementById('users-count'); 60 - 61 - function onAuth() { 62 - if (!U.isAdmin()) { 63 - U.hide(document.getElementById('auth-view')); 64 - U.hide(document.getElementById('unauth-view')); 65 - U.show(document.getElementById('forbidden-view')); 66 - return; 67 - } 68 - U.show(document.getElementById('auth-view')); 69 - U.hide(document.getElementById('unauth-view')); 70 - U.hide(document.getElementById('forbidden-view')); 71 - loadUsers(); 72 - } 73 - 74 - function onUnauth() { 75 - U.hide(document.getElementById('auth-view')); 76 - U.hide(document.getElementById('forbidden-view')); 77 - U.show(document.getElementById('unauth-view')); 78 - } 79 47 80 48 async function loadUsers() { 81 49 try { ··· 158 126 } catch (_) {} 159 127 } 160 128 161 - U.checkSession(onAuth, onUnauth); 129 + U.authGate(loadUsers, { requireAdmin: true }); 162 130 })(); 163 131 </script> 164 132 </body>
+1 -3
transparent.go
··· 273 273 return 274 274 } 275 275 if r.URL.Path == "/" || r.URL.Path == "/setup" { 276 - w.Header().Set("Content-Type", "text/html; charset=utf-8") 277 - w.WriteHeader(http.StatusOK) 278 - setupTmpl.Execute(w, setupData{ 276 + servePage(w, setupTmpl, setupData{ 279 277 PortalURL: h.proxy.portalOrigin, 280 278 HttpOrigin: h.proxy.httpOrigin, 281 279 Transparent: true,