feat: support subpath mount via reverse proxy — v0.50.67 (PR #588 by @vcavichini)

Squash-merges feature from PR #588 by @vcavichini. Dynamic <base href> injection + api() helper slash-stripping enables deploying hermes-webui behind a reverse proxy at any subpath without configuration. Also fixes pre-existing bug: api/upload was using location.origin instead of location.href (closes #596). Co-authored-by: vcavichini <vcavichini@users.noreply.github.com>
This commit is contained in:
nesquena-hermes
2026-04-16 11:20:08 -07:00
committed by GitHub
parent 8a1bc134fa
commit 54e83fb8b6
14 changed files with 49 additions and 40 deletions

View File

@@ -1,5 +1,10 @@
# Hermes Web UI -- Changelog # Hermes Web UI -- Changelog
## [v0.50.67] — 2026-04-16
### Added
- **Subpath mount support** — Hermes WebUI can now be served behind a reverse proxy at any subpath (e.g. `/hermes-webui/` via Tailscale Serve, nginx, or Caddy). A dynamic `<base href>` is injected as the first script in `<head>`, and all client-side URL references are converted from absolute to relative. The server-side route handlers are unchanged. No configuration needed — works transparently for both root (`/`) and subpath deployments. (PR #588 by @vcavichini)
## [v0.50.66] — 2026-04-16 ## [v0.50.66] — 2026-04-16
### Fixed ### Fixed

View File

@@ -2,7 +2,7 @@ async function cancelStream(){
const streamId = S.activeStreamId; const streamId = S.activeStreamId;
if(!streamId) return; if(!streamId) return;
try{ try{
await fetch(new URL(`/api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{credentials:'include'}); await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,location.href).href,{credentials:'include'});
}catch(e){/* cancel request failed — cleanup below still runs */} }catch(e){/* cancel request failed — cleanup below still runs */}
// Clear status unconditionally after the cancel request completes. // Clear status unconditionally after the cancel request completes.
// The SSE cancel event may also fire, but if the connection is already // The SSE cancel event may also fire, but if the connection is already
@@ -226,7 +226,7 @@ $('btnAttach').onclick=()=>$('fileInput').click();
form.append('file',new File([blob],`voice-input.${ext}`,{type:blob.type||`audio/${ext}`})); form.append('file',new File([blob],`voice-input.${ext}`,{type:blob.type||`audio/${ext}`}));
setComposerStatus('Transcribing…'); setComposerStatus('Transcribing…');
try{ try{
const res=await fetch('/api/transcribe',{method:'POST',body:form}); const res=await fetch('api/transcribe',{method:'POST',body:form});
const data=await res.json().catch(()=>({})); const data=await res.json().catch(()=>({}));
if(!res.ok) throw new Error(data.error||'Transcription failed'); if(!res.ok) throw new Error(data.error||'Transcription failed');
_commitTranscript(data.transcript||''); _commitTranscript(data.transcript||'');

View File

@@ -4,8 +4,10 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hermes</title> <title>Hermes</title>
<!-- base href enables subpath mount support; all static paths must stay relative (no leading slash) -->
<script>(function(){var p=location.pathname.endsWith('/')?location.pathname:(location.pathname.replace(/\/[^\/]*$/,'/')||'/');document.write('<base href="'+location.origin+p+'">');})()</script>
<script>(function(){var t=localStorage.getItem('hermes-theme');if(t==='system'){t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';}if(t&&t!=='dark')document.documentElement.dataset.theme=t;})()</script> <script>(function(){var t=localStorage.getItem('hermes-theme');if(t==='system'){t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';}if(t&&t!=='dark')document.documentElement.dataset.theme=t;})()</script>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="static/style.css">
<!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) --> <!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css" integrity="sha384-5TcZemv2l/9On385z///+d7MSYlvIEw9FuZTIdZ14vJLqWphw7e7ZPuOiCHJcFCP" crossorigin="anonymous"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css" integrity="sha384-5TcZemv2l/9On385z///+d7MSYlvIEw9FuZTIdZ14vJLqWphw7e7ZPuOiCHJcFCP" crossorigin="anonymous">
<!-- Prism.js syntax highlighting (loaded async, non-blocking) --> <!-- Prism.js syntax highlighting (loaded async, non-blocking) -->
@@ -553,7 +555,7 @@
<div class="settings-section-title">System</div> <div class="settings-section-title">System</div>
<div class="settings-section-meta">Instance version and access controls.</div> <div class="settings-section-meta">Instance version and access controls.</div>
</div> </div>
<span class="settings-version-badge">v0.50.66</span> <span class="settings-version-badge">v0.50.67</span>
</div> </div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px"> <div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label> <label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
@@ -587,15 +589,15 @@
</div> </div>
</div> </div>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="/static/i18n.js"></script> <script src="static/i18n.js"></script>
<script src="/static/icons.js"></script> <script src="static/icons.js"></script>
<script src="/static/ui.js"></script> <script src="static/ui.js"></script>
<script src="/static/workspace.js"></script> <script src="static/workspace.js"></script>
<script src="/static/sessions.js"></script> <script src="static/sessions.js"></script>
<script src="/static/commands.js"></script> <script src="static/commands.js"></script>
<script src="/static/messages.js"></script> <script src="static/messages.js"></script>
<script src="/static/panels.js"></script> <script src="static/panels.js"></script>
<script src="/static/onboarding.js"></script> <script src="static/onboarding.js"></script>
<script src="/static/boot.js"></script> <script src="static/boot.js"></script>
</body> </body>
</html> </html>

View File

@@ -26,7 +26,7 @@ document.addEventListener('DOMContentLoaded', function () {
var pw = input.value; var pw = input.value;
hideErr(); hideErr();
try { try {
var res = await fetch('/api/auth/login', { var res = await fetch('api/auth/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw }), body: JSON.stringify({ password: pw }),
@@ -35,7 +35,7 @@ document.addEventListener('DOMContentLoaded', function () {
var data = {}; var data = {};
try { data = await res.json(); } catch (_) {} try { data = await res.json(); } catch (_) {}
if (res.ok && data.ok) { if (res.ok && data.ok) {
window.location.href = '/'; window.location.href = './';
} else { } else {
showErr(data.error || invalidPw); showErr(data.error || invalidPw);
} }

View File

@@ -533,7 +533,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`); const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`);
if(st.active){ if(st.active){
setComposerStatus('Reconnected'); setComposerStatus('Reconnected');
_wireSSE(new EventSource(new URL(`/api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{withCredentials:true})); _wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,location.href).href,{withCredentials:true}));
return; return;
} }
}catch(_){} }catch(_){}
@@ -633,7 +633,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
} }
}catch(_){} }catch(_){}
} }
_wireSSE(new EventSource(new URL(`/api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{withCredentials:true})); _wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,location.href).href,{withCredentials:true}));
})(); })();
} }

View File

@@ -1347,7 +1347,7 @@ async function saveSettings(andClose){
async function signOut(){ async function signOut(){
try{ try{
await api('/api/auth/logout',{method:'POST',body:'{}'}); await api('/api/auth/logout',{method:'POST',body:'{}'});
window.location.href='/login'; window.location.href='login';
}catch(e){ }catch(e){
showToast(t('sign_out_failed')+e.message); showToast(t('sign_out_failed')+e.message);
} }

View File

@@ -344,7 +344,7 @@ function startGatewaySSE(){
stopGatewaySSE(); stopGatewaySSE();
if(!window._showCliSessions) return; if(!window._showCliSessions) return;
try{ try{
_gatewaySSE = new EventSource('/api/sessions/gateway/stream'); _gatewaySSE = new EventSource('api/sessions/gateway/stream');
_gatewaySSE.addEventListener('sessions_changed', (ev) => { _gatewaySSE.addEventListener('sessions_changed', (ev) => {
try{ try{
const data = JSON.parse(ev.data); const data = JSON.parse(ev.data);

View File

@@ -65,7 +65,7 @@ async function populateModelDropdown(){
const sel=$('modelSelect'); const sel=$('modelSelect');
if(!sel) return; if(!sel) return;
try{ try{
const data=await fetch(new URL('/api/models',location.origin).href,{credentials:'include'}).then(r=>r.json()); const data=await fetch(new URL('api/models',location.href).href,{credentials:'include'}).then(r=>r.json());
if(!data.groups||!data.groups.length) return; // keep HTML defaults if(!data.groups||!data.groups.length) return; // keep HTML defaults
// Store active provider globally so the send path can warn on mismatch // Store active provider globally so the send path can warn on mismatch
window._activeProvider=data.active_provider||null; window._activeProvider=data.active_provider||null;
@@ -108,7 +108,7 @@ async function _fetchLiveModels(provider, sel){
// All providers now supported via agent's provider_model_ids() — no exclusions needed // All providers now supported via agent's provider_model_ids() — no exclusions needed
if(_liveModelCache[provider]) return; // already fetched this session if(_liveModelCache[provider]) return; // already fetched this session
try{ try{
const url=new URL('/api/models/live',location.origin); const url=new URL('api/models/live',location.href);
url.searchParams.set('provider',provider); url.searchParams.set('provider',provider);
const data=await fetch(url.href,{credentials:'include'}).then(r=>r.json()); const data=await fetch(url.href,{credentials:'include'}).then(r=>r.json());
if(!data.models||!data.models.length) return; if(!data.models||!data.models.length) return;
@@ -582,7 +582,7 @@ function renderMd(raw){
return `<a href="${esc(ref)}" target="_blank" rel="noopener">${esc(ref)}</a>`; return `<a href="${esc(ref)}" target="_blank" rel="noopener">${esc(ref)}</a>`;
} }
// Local file path // Local file path
const apiUrl='/api/media?path='+encodeURIComponent(ref); const apiUrl='api/media?path='+encodeURIComponent(ref);
if(_IMAGE_EXTS.test(ref)){ if(_IMAGE_EXTS.test(ref)){
return `<img class="msg-media-img" src="${esc(apiUrl)}" alt="${esc(ref.split('/').pop())}" loading="lazy" onclick="this.classList.toggle('msg-media-img--full')">`; return `<img class="msg-media-img" src="${esc(apiUrl)}" alt="${esc(ref.split('/').pop())}" loading="lazy" onclick="this.classList.toggle('msg-media-img--full')">`;
} }
@@ -1854,7 +1854,7 @@ async function uploadPendingFiles(){
const f=S.pendingFiles[i];const fd=new FormData(); const f=S.pendingFiles[i];const fd=new FormData();
fd.append('session_id',S.session.session_id);fd.append('file',f,f.name); fd.append('session_id',S.session.session_id);fd.append('file',f,f.name);
try{ try{
const res=await fetch(new URL('/api/upload',location.origin).href,{method:'POST',credentials:'include',body:fd}); const res=await fetch(new URL('api/upload',location.href).href,{method:'POST',credentials:'include',body:fd});
if(!res.ok){const err=await res.text();throw new Error(err);} if(!res.ok){const err=await res.text();throw new Error(err);}
const data=await res.json(); const data=await res.json();
if(data.error)throw new Error(data.error); if(data.error)throw new Error(data.error);

View File

@@ -1,5 +1,7 @@
async function api(path,opts={}){ async function api(path,opts={}){
const url=new URL(path,location.origin); // Strip leading slash so URL resolves relative to location.href (supports subpath mounts)
const rel = path.startsWith('/') ? path.slice(1) : path;
const url=new URL(rel,location.href);
const res=await fetch(url.href,{credentials:'include',headers:{'Content-Type':'application/json'},...opts}); const res=await fetch(url.href,{credentials:'include',headers:{'Content-Type':'application/json'},...opts});
if(!res.ok){ if(!res.ok){
const text=await res.text(); const text=await res.text();
@@ -204,7 +206,7 @@ async function openFile(path){
if(IMAGE_EXTS.has(ext)){ if(IMAGE_EXTS.has(ext)){
// Image: load via raw endpoint, show as <img> // Image: load via raw endpoint, show as <img>
showPreview('image'); showPreview('image');
const url=`/api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`; const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`;
$('previewImg').alt=path; $('previewImg').alt=path;
$('previewImg').src=url; $('previewImg').src=url;
$('previewImg').onerror=()=>setStatus(t('image_load_failed')); $('previewImg').onerror=()=>setStatus(t('image_load_failed'));
@@ -238,7 +240,7 @@ async function openFile(path){
function downloadFile(path){ function downloadFile(path){
if(!S.session)return; if(!S.session)return;
// Trigger browser download via the raw file endpoint with content-disposition attachment // Trigger browser download via the raw file endpoint with content-disposition attachment
const url=`/api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&download=1`; const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&download=1`;
const filename=path.split('/').pop(); const filename=path.split('/').pop();
const a=document.createElement('a'); const a=document.createElement('a');
a.href=url;a.download=filename; a.href=url;a.download=filename;

View File

@@ -47,8 +47,8 @@ class TestMediaRenderMdStash(unittest.TestCase):
"restore pass must produce download link for non-image files") "restore pass must produce download link for non-image files")
def test_media_api_url_pattern(self): def test_media_api_url_pattern(self):
self.assertIn("/api/media?path=", UI_JS, self.assertIn("api/media?path=", UI_JS,
"renderMd must build /api/media?path=... URL for local files") "renderMd must build api/media?path=... URL for local files")
def test_media_stash_uses_null_byte_token(self): def test_media_stash_uses_null_byte_token(self):
self.assertIn("\\x00D", UI_JS, self.assertIn("\\x00D", UI_JS,

View File

@@ -13,7 +13,7 @@ def test_index_contains_onboarding_overlay_markup():
assert 'id="onboardingOverlay"' in html assert 'id="onboardingOverlay"' in html
assert 'id="onboardingBody"' in html assert 'id="onboardingBody"' in html
assert 'id="onboardingNextBtn"' in html assert 'id="onboardingNextBtn"' in html
assert 'src="/static/onboarding.js"' in html assert 'src="static/onboarding.js"' in html
def test_onboarding_css_rules_exist(): def test_onboarding_css_rules_exist():

View File

@@ -94,7 +94,7 @@ def test_cancel_button_in_html(cleanup_test_sessions):
def test_cancel_function_in_boot_js(cleanup_test_sessions): def test_cancel_function_in_boot_js(cleanup_test_sessions):
src, _ = get_text("/static/boot.js") src, _ = get_text("/static/boot.js")
assert "async function cancelStream(" in src assert "async function cancelStream(" in src
assert "/api/chat/cancel" in src assert "api/chat/cancel" in src
# ── Cron history ─────────────────────────────────────────────────────────── # ── Cron history ───────────────────────────────────────────────────────────

View File

@@ -329,7 +329,7 @@ def test_boot_js_browser_unsupported_guard_uses_fallback_capabilities():
def test_boot_js_media_recorder_fallback_posts_to_transcribe_api(): def test_boot_js_media_recorder_fallback_posts_to_transcribe_api():
"""Desktop fallback must send recorded audio to /api/transcribe for transcription.""" """Desktop fallback must send recorded audio to /api/transcribe for transcription."""
js, _ = get_text("/static/boot.js") js, _ = get_text("/static/boot.js")
assert '/api/transcribe' in js assert 'api/transcribe' in js
assert 'fetch(' in js assert 'fetch(' in js

View File

@@ -67,20 +67,20 @@ def test_boot_js_served(cleanup_test_sessions):
def test_app_js_no_longer_referenced_in_html(cleanup_test_sessions): def test_app_js_no_longer_referenced_in_html(cleanup_test_sessions):
"""index.html must not reference the old monolithic app.js.""" """index.html must not reference the old monolithic app.js."""
html = get_text("/") html = get_text("/")
assert 'src="/static/app.js"' not in html assert 'src="static/app.js"' not in html
# All 6 modules must be present # All 6 modules must be present
for module in ["ui.js", "workspace.js", "sessions.js", "messages.js", "panels.js", "boot.js"]: for module in ["ui.js", "workspace.js", "sessions.js", "messages.js", "panels.js", "boot.js"]:
assert f'src="/static/{module}"' in html, f"Missing {module} in index.html" assert f'src="static/{module}"' in html, f"Missing {module} in index.html"
def test_module_load_order_correct(cleanup_test_sessions): def test_module_load_order_correct(cleanup_test_sessions):
"""ui.js must appear before sessions.js which must appear before boot.js.""" """ui.js must appear before sessions.js which must appear before boot.js."""
html = get_text("/") html = get_text("/")
ui_pos = html.find('src="/static/ui.js"') ui_pos = html.find('src="static/ui.js"')
ws_pos = html.find('src="/static/workspace.js"') ws_pos = html.find('src="static/workspace.js"')
sess_pos = html.find('src="/static/sessions.js"') sess_pos = html.find('src="static/sessions.js"')
msg_pos = html.find('src="/static/messages.js"') msg_pos = html.find('src="static/messages.js"')
panels_pos = html.find('src="/static/panels.js"') panels_pos = html.find('src="static/panels.js"')
boot_pos = html.find('src="/static/boot.js"') boot_pos = html.find('src="static/boot.js"')
assert ui_pos < ws_pos < sess_pos < msg_pos < panels_pos < boot_pos assert ui_pos < ws_pos < sess_pos < msg_pos < panels_pos < boot_pos
def test_no_duplicate_function_definitions(cleanup_test_sessions): def test_no_duplicate_function_definitions(cleanup_test_sessions):