furtka/assets/www/index.html
Daniel Maksymilian Syrnicki 26f0424ae3
All checks were successful
Build ISO / build-iso (push) Successful in 17m14s
CI / lint (push) Successful in 26s
CI / test (push) Successful in 1m2s
CI / validate-json (push) Successful in 24s
CI / markdown-links (push) Successful in 15s
Release / release (push) Successful in 11m26s
fix: auth-guard / and /settings, add Logout link to static navs
Since 26.11 shipped login, two of the three nav pages were secretly
unauthenticated. The Caddyfile only reverse-proxied /api/*, /apps*,
/login*, /logout* to the Python auth-gated handler. Everything else —
including / (landing page) and /settings/ — fell through to Caddy's
catch-all file_server straight out of assets/www/, skipping the
session check entirely.

LAN visitor effect: they could read the box's hostname, IP, Furtka
version, uptime, and see all the Update-now / Reboot / HTTPS-toggle
buttons on /settings/. The API calls those buttons fired were
themselves 401-gated so nothing actually happened — but the info leak
plus "looks open" UX was real. Caught in the 26.13 SSH test session
when the user noticed Logout only appeared in the nav on /apps, and
not on / or /settings/.

Fix:
- Caddyfile: new `handle /settings*` and `handle /` blocks in the
  shared `(furtka_routes)` snippet reverse-proxy to localhost:7000,
  so both hit the Python auth-guard before the HTML goes out.
- api.py: new `_serve_static_www(relative_path)` helper reads
  assets/www/{index.html, settings/index.html} with a path-traversal
  clamp (resolved path must stay under static_www_dir). `do_GET`
  routes `/` and `/settings[/]` to it. Removed the `/` branch from
  the old combined-with-/apps line — those are different pages now.
- paths.py: new `static_www_dir()` helper with `FURTKA_STATIC_WWW`
  env override for tests.
- assets/www/*.html: both nav bars get the Logout link + a shared
  `doLogout()` inline script matching the _HTML pattern. Users never
  see the link unauthed (the Python handler 302s them before the
  page renders), but authed users get consistent navigation across
  all three pages.

Tests: 5 new cases in test_api.py — unauth / redirects, unauth
/settings redirects (both trailing-slash and not), authed / serves
index.html, authed /settings serves settings/index.html,
regression guard that / and /apps serve different content.
Existing test updated (the one that used / as a proxy for /apps).

Static /style.css, /rootCA.crt, /status.json, /furtka.json,
/update-state.json stay served by Caddy's catch-all — those are
public by design (login page needs style.css, fresh users need the
CA to trust HTTPS, runtime JSON is metadata not creds).

272 tests pass, ruff check + format clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:16:42 +02:00

171 lines
7.3 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Furtka</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<main class="wrap">
<nav class="nav">
<a class="brand" href="/">Furtka</a>
<div class="nav-links">
<a href="/" aria-current="page">Home</a>
<a href="/apps">Apps</a>
<a href="/settings/">Settings</a>
<a href="#" id="logout-link" onclick="return doLogout(event)">Logout</a>
</div>
</nav>
<header>
<h1>Welcome to Furtka</h1>
<p class="lead">Your home server is ready.</p>
<p class="host">Running on <code id="hostname"></code></p>
</header>
<section>
<h2>Your apps</h2>
<div id="apps-section" class="grid-apps">
<a class="app-tile" href="/apps"><span class="name">Loading…</span></a>
</div>
</section>
<section>
<h2>System status</h2>
<div class="tiles">
<div class="tile">
<span class="label">Uptime</span>
<span class="value" id="uptime"></span>
</div>
<div class="tile">
<span class="label">Docker</span>
<span class="value" id="docker"></span>
</div>
<div class="tile">
<span class="label">Free disk</span>
<span class="value" id="disk"></span>
</div>
</div>
<p class="updated">Updated <span id="updated"></span></p>
</section>
<section>
<h2>Coming next</h2>
<div class="coming">
<p class="hint">Features we're building — follow progress on <a href="https://furtka.org">furtka.org</a>.</p>
<a href="https://furtka.org/#planned">Photos</a>
<a href="https://furtka.org/#planned">Smart home</a>
<a href="https://furtka.org/#planned">Media streaming</a>
<a href="https://furtka.org/#planned">Multiple boxes</a>
<a href="https://furtka.org/#planned">Secure link</a>
<a href="https://furtka.org/#planned">User accounts</a>
</div>
</section>
<footer>
<p>Furtka · <a href="https://furtka.org">furtka.org</a></p>
</footer>
</main>
<script>
// Revoke the cookie server-side and bounce to /login. Shared
// shape with the _HTML in furtka/api.py so the two logout
// links behave identically.
async function doLogout(ev) {
ev.preventDefault();
try { await fetch('/logout', { method: 'POST', credentials: 'same-origin' }); }
catch (e) { /* server may already be down */ }
window.location.href = '/login';
return false;
}
// Hostname + install metadata — written once at install time to
// /var/lib/furtka/furtka.json (see _furtka_json_cmd in the installer).
// Separate from status.json because these facts don't change between
// refresh ticks.
let HOSTNAME = "";
const FALLBACK_ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v12a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-7l-2-2H5a2 2 0 0 0-2 2z"/></svg>';
function esc(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
async function loadFurtkaJson() {
try {
const r = await fetch('/furtka.json', { cache: 'no-store' });
if (!r.ok) return;
const f = await r.json();
HOSTNAME = f.hostname || "";
const el = document.getElementById('hostname');
if (el) el.textContent = HOSTNAME || '—';
} catch (e) { /* no-op */ }
}
function primaryAction(app) {
// open_url is a manifest-declared template with a `{host}`
// placeholder — substituted against the current browser's
// hostname so smb://host/files and http://host:3001/ both
// follow however the user reached Furtka (furtka.local, raw
// IP, a future reverse-proxy hostname). Apps without a
// frontend fall back to /apps for management.
if (app.open_url) {
const host = HOSTNAME || location.hostname;
return { href: app.open_url.replace('{host}', host), label: 'Open', external: true };
}
return { href: '/apps', label: 'Manage →', external: false };
}
async function renderApps() {
const target = document.getElementById('apps-section');
try {
const r = await fetch('/api/apps', { cache: 'no-store' });
if (!r.ok) throw new Error('api');
const apps = (await r.json()).filter(a => a.ok !== false);
if (!apps.length) {
target.innerHTML = '<a class="app-tile" href="/apps">' +
'<span class="name">No apps yet</span>' +
'<span class="cta">Install your first app →</span></a>';
return;
}
target.innerHTML = apps.map(a => {
const icon = a.icon_svg || FALLBACK_ICON;
const { href, label, external } = primaryAction(a);
const tgt = external ? ' target="_blank" rel="noopener"' : '';
return `<a class="app-tile" href="${esc(href)}"${tgt}>
<div class="icon">${icon}</div>
<span class="name">${esc(a.display_name || a.name)}</span>
<span class="cta">${esc(label)}</span>
</a>`;
}).join('');
} catch (e) {
target.innerHTML = '<a class="app-tile" href="/apps">' +
'<span class="name">Manage apps</span>' +
'<span class="cta">Open →</span></a>';
}
}
async function refresh() {
try {
const r = await fetch('/status.json', { cache: 'no-store' });
if (!r.ok) return;
const s = await r.json();
document.getElementById('uptime').textContent = s.uptime || '—';
document.getElementById('docker').textContent = s.docker_version || '—';
document.getElementById('disk').textContent = s.disk_free || '—';
document.getElementById('updated').textContent = s.updated_at || '—';
} catch (e) {
/* next tick will retry */
}
}
// furtka.json must land first so renderApps can build the SMB link
// with the real hostname. If it 404s (very early in boot) the
// primary-action falls back to "Manage →".
loadFurtkaJson().then(renderApps);
refresh();
setInterval(refresh, 15000);
</script>
</body>
</html>