Move browser portal to k_proxy
This commit is contained in:
parent
4893eb8312
commit
d0d27a0896
14
Setup.md
14
Setup.md
|
|
@ -345,6 +345,20 @@ Session note (2026-04-25, Phase 6 enrollment contract):
|
||||||
- Phase 6 now has a real `k_client -> k_proxy` enrollment request path
|
- Phase 6 now has a real `k_client -> k_proxy` enrollment request path
|
||||||
- the remaining gap is not basic routing; it is deciding the final enrollment semantics and whether the browser should stay behind a local portal or talk to `k_proxy` directly
|
- the remaining gap is not basic routing; it is deciding the final enrollment semantics and whether the browser should stay behind a local portal or talk to `k_proxy` directly
|
||||||
|
|
||||||
|
Session note (2026-04-25, browser target moved to k_proxy):
|
||||||
|
- `k_proxy` now serves the browser-facing portal UI directly on `/` over `https://127.0.0.1:9771`.
|
||||||
|
- `k_client_portal.py` is now a temporary bridge page:
|
||||||
|
- it points users to `https://127.0.0.1:9771/`
|
||||||
|
- it is no longer the primary browser target
|
||||||
|
- Verified direct browser/API target behavior from `k_client`:
|
||||||
|
- `GET https://127.0.0.1:9771/` returns the proxy portal HTML
|
||||||
|
- `GET https://127.0.0.1:9771/health` returns `ok=true`
|
||||||
|
- direct `POST /enroll/register` for `carol` succeeds
|
||||||
|
- direct `POST /session/login` for `carol` succeeds
|
||||||
|
- Current implication:
|
||||||
|
- browser traffic is now intended to go straight to `k_proxy`
|
||||||
|
- the `k_client` portal remains only as a temporary bridge/compatibility layer
|
||||||
|
|
||||||
Session note (2026-04-25, in-VM forwarding test):
|
Session note (2026-04-25, in-VM forwarding test):
|
||||||
- Tested the intended in-VM forwarding path with `qvm-connect-tcp` instead of host-side `qrexec-client-vm`.
|
- Tested the intended in-VM forwarding path with `qvm-connect-tcp` instead of host-side `qrexec-client-vm`.
|
||||||
- Forwarders start and bind locally:
|
- Forwarders start and bind locally:
|
||||||
|
|
|
||||||
14
Workplan.md
14
Workplan.md
|
|
@ -265,9 +265,8 @@ Exit criteria:
|
||||||
Status (2026-04-25):
|
Status (2026-04-25):
|
||||||
- Added first `k_client` implementation at `/home/user/chromecard/k_client_portal.py`.
|
- Added first `k_client` implementation at `/home/user/chromecard/k_client_portal.py`.
|
||||||
- Current prototype flow:
|
- Current prototype flow:
|
||||||
- browser talks to local portal on `k_client`
|
- browser now targets `k_proxy` directly over `https://127.0.0.1:9771`
|
||||||
- portal stores only a preferred username locally
|
- `k_client_portal.py` remains only as a temporary bridge page
|
||||||
- portal calls `k_proxy` over `https://127.0.0.1:9771`
|
|
||||||
- `k_proxy` continues to authenticate with the card and forward to `k_server`
|
- `k_proxy` continues to authenticate with the card and forward to `k_server`
|
||||||
- Verified end-to-end through the portal:
|
- Verified end-to-end through the portal:
|
||||||
- enroll `alice`
|
- enroll `alice`
|
||||||
|
|
@ -278,12 +277,13 @@ Status (2026-04-25):
|
||||||
- Enrollment contract progress:
|
- Enrollment contract progress:
|
||||||
- `k_proxy` now exposes prototype enrollment endpoints
|
- `k_proxy` now exposes prototype enrollment endpoints
|
||||||
- proxy-side enrollment storage exists and is checked before login is allowed
|
- proxy-side enrollment storage exists and is checked before login is allowed
|
||||||
- `k_client` portal enrollment now routes to `k_proxy` over TLS instead of remaining client-local only
|
- direct browser/API traffic can now use those proxy endpoints without going through the local bridge
|
||||||
- Phase 6 is materially further along for the current prototype shape:
|
- Phase 6 is materially further along for the current prototype shape:
|
||||||
- client-side process exists
|
- direct browser target is on `k_proxy`
|
||||||
- login/resource flow is integrated
|
- login/resource flow is integrated on the direct proxy path
|
||||||
- enrollment now has a real client->proxy path
|
- enrollment now has a real client->proxy path
|
||||||
- final enrollment semantics and UI shape are still provisional
|
- the `k_client` bridge remains only for transition/compatibility
|
||||||
|
- final enrollment semantics are still provisional
|
||||||
|
|
||||||
## Phase 6.5: Concurrency and Multi-Client Test Setup
|
## Phase 6.5: Concurrency and Multi-Client Test Setup
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,35 +27,34 @@ HTML = """<!doctype html>
|
||||||
<head>
|
<head>
|
||||||
<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>ChromeCard Client Portal</title>
|
<title>ChromeCard Client Bridge</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #f4efe4;
|
--bg: #f3efe8;
|
||||||
--panel: #fffaf1;
|
--panel: #fffdf8;
|
||||||
--ink: #1a1712;
|
--ink: #181614;
|
||||||
--muted: #6d6456;
|
--muted: #655f56;
|
||||||
--line: #d7c9b2;
|
--line: #d9cfbf;
|
||||||
--accent: #1c6b57;
|
--accent: #0c6a60;
|
||||||
--accent-2: #ae6a2b;
|
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: "Iowan Old Style", "Palatino Linotype", serif;
|
font-family: "Iowan Old Style", "Palatino Linotype", serif;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top left, rgba(174,106,43,0.16), transparent 34%),
|
radial-gradient(circle at top left, rgba(12,106,96,0.12), transparent 34%),
|
||||||
linear-gradient(180deg, #f9f3e8 0%, var(--bg) 100%);
|
linear-gradient(180deg, #f9f3e8 0%, var(--bg) 100%);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
main {
|
main {
|
||||||
max-width: 880px;
|
max-width: 760px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 32px 20px 56px;
|
padding: 32px 20px 56px;
|
||||||
}
|
}
|
||||||
.hero {
|
.panel {
|
||||||
padding: 22px 24px;
|
padding: 22px 24px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: linear-gradient(135deg, rgba(255,250,241,0.98), rgba(243,232,214,0.95));
|
background: linear-gradient(135deg, rgba(255,253,248,0.98), rgba(242,237,228,0.94));
|
||||||
box-shadow: 0 18px 40px rgba(55, 41, 19, 0.08);
|
box-shadow: 0 18px 40px rgba(55, 41, 19, 0.08);
|
||||||
}
|
}
|
||||||
h1 {
|
h1 {
|
||||||
|
|
@ -70,42 +69,14 @@ HTML = """<!doctype html>
|
||||||
max-width: 62ch;
|
max-width: 62ch;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
||||||
gap: 18px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
padding: 18px;
|
|
||||||
}
|
|
||||||
.card h2 {
|
|
||||||
margin: 0 0 12px;
|
|
||||||
font-size: 1.15rem;
|
|
||||||
}
|
|
||||||
label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.92rem;
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: #fff;
|
|
||||||
font: inherit;
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 14px;
|
margin-top: 18px;
|
||||||
}
|
}
|
||||||
button {
|
a.button, button {
|
||||||
|
text-decoration: none;
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
|
@ -113,16 +84,8 @@ HTML = """<!doctype html>
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
button.secondary { background: var(--accent-2); }
|
|
||||||
.status {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 14px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
pre {
|
pre {
|
||||||
margin: 18px 0 0;
|
margin: 18px 0 0;
|
||||||
min-height: 280px;
|
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
|
|
@ -136,48 +99,21 @@ HTML = """<!doctype html>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<section class="hero">
|
<section class="panel">
|
||||||
<h1>ChromeCard Client Portal</h1>
|
<h1>ChromeCard Client Bridge</h1>
|
||||||
<p class="subtitle">
|
<p class="subtitle">
|
||||||
Prototype browser flow for k_client. Enrollment state lives here. Login,
|
Browser traffic should now target `k_proxy` directly at `https://127.0.0.1:9771/`.
|
||||||
session status, counter access, and logout go through the current k_proxy TLS API.
|
This local service remains only as a temporary bridge and compatibility shim.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="grid">
|
|
||||||
<div class="card">
|
|
||||||
<h2>Enrollment</h2>
|
|
||||||
<label for="username">Username</label>
|
|
||||||
<input id="username" placeholder="alice" autocomplete="off">
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button id="enrollBtn">Enroll User</button>
|
<a class="button" href="https://127.0.0.1:9771/">Open Proxy Portal</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="status">
|
|
||||||
<div>Enrolled user: <strong id="enrolledUser">none</strong></div>
|
|
||||||
<div>Session active: <strong id="sessionActive">no</strong></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Session Flow</h2>
|
|
||||||
<div class="actions">
|
|
||||||
<button id="loginBtn">Login</button>
|
|
||||||
<button id="statusBtn" class="secondary">Status</button>
|
|
||||||
<button id="counterBtn">Counter</button>
|
|
||||||
<button id="logoutBtn" class="secondary">Logout</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<pre id="log"></pre>
|
<pre id="log"></pre>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const logNode = document.getElementById("log");
|
const logNode = document.getElementById("log");
|
||||||
const enrolledUserNode = document.getElementById("enrolledUser");
|
|
||||||
const sessionActiveNode = document.getElementById("sessionActive");
|
|
||||||
const usernameNode = document.getElementById("username");
|
|
||||||
|
|
||||||
function log(message, payload) {
|
function log(message, payload) {
|
||||||
const stamp = new Date().toLocaleTimeString();
|
const stamp = new Date().toLocaleTimeString();
|
||||||
let line = `[${stamp}] ${message}`;
|
let line = `[${stamp}] ${message}`;
|
||||||
|
|
@ -186,81 +122,8 @@ HTML = """<!doctype html>
|
||||||
}
|
}
|
||||||
logNode.textContent = line + "\\n\\n" + logNode.textContent;
|
logNode.textContent = line + "\\n\\n" + logNode.textContent;
|
||||||
}
|
}
|
||||||
|
log("Primary browser target moved", {open: "https://127.0.0.1:9771/"});
|
||||||
async function api(path, payload) {
|
setTimeout(() => { window.location.href = "https://127.0.0.1:9771/"; }, 1200);
|
||||||
const resp = await fetch(path, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {"Content-Type": "application/json"},
|
|
||||||
body: JSON.stringify(payload || {})
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!resp.ok) {
|
|
||||||
throw new Error(JSON.stringify(data));
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshState() {
|
|
||||||
const resp = await fetch("/api/client/state");
|
|
||||||
const data = await resp.json();
|
|
||||||
enrolledUserNode.textContent = data.enrolled_username || "none";
|
|
||||||
sessionActiveNode.textContent = data.session_active ? "yes" : "no";
|
|
||||||
if (data.enrolled_username && !usernameNode.value) {
|
|
||||||
usernameNode.value = data.enrolled_username;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById("enrollBtn").addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
const data = await api("/api/enroll", {username: usernameNode.value});
|
|
||||||
log("Enrollment updated", data);
|
|
||||||
await refreshState();
|
|
||||||
} catch (err) {
|
|
||||||
log("Enrollment failed", {error: err.message});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("loginBtn").addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
const data = await api("/api/login");
|
|
||||||
log("Login ok", data);
|
|
||||||
await refreshState();
|
|
||||||
} catch (err) {
|
|
||||||
log("Login failed", {error: err.message});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("statusBtn").addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
const data = await api("/api/status");
|
|
||||||
log("Session status", data);
|
|
||||||
await refreshState();
|
|
||||||
} catch (err) {
|
|
||||||
log("Status failed", {error: err.message});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("counterBtn").addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
const data = await api("/api/resource/counter");
|
|
||||||
log("Counter response", data);
|
|
||||||
await refreshState();
|
|
||||||
} catch (err) {
|
|
||||||
log("Counter failed", {error: err.message});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("logoutBtn").addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
const data = await api("/api/logout");
|
|
||||||
log("Logout response", data);
|
|
||||||
await refreshState();
|
|
||||||
} catch (err) {
|
|
||||||
log("Logout failed", {error: err.message});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
refreshState().catch((err) => log("Initial state load failed", {error: err.message}));
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
279
k_proxy_app.py
279
k_proxy_app.py
|
|
@ -30,6 +30,274 @@ from urllib.parse import urlparse
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
|
HTML = """<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>ChromeCard Proxy Portal</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f1eee8;
|
||||||
|
--panel: #fffdf8;
|
||||||
|
--ink: #171615;
|
||||||
|
--muted: #645f56;
|
||||||
|
--line: #d6cbb9;
|
||||||
|
--accent: #0c6a60;
|
||||||
|
--accent-2: #8e5b2d;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Iowan Old Style", "Palatino Linotype", serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(12,106,96,0.12), transparent 32%),
|
||||||
|
radial-gradient(circle at left center, rgba(142,91,45,0.10), transparent 28%),
|
||||||
|
linear-gradient(180deg, #faf7f0 0%, var(--bg) 100%);
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 20px 56px;
|
||||||
|
}
|
||||||
|
.hero, .card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
box-shadow: 0 16px 34px rgba(49, 38, 21, 0.08);
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: clamp(2rem, 4vw, 3.5rem);
|
||||||
|
line-height: 0.95;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
max-width: 64ch;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
}
|
||||||
|
.card { padding: 18px; }
|
||||||
|
.card h2 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font: inherit;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button.secondary { background: var(--accent-2); }
|
||||||
|
.status {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
margin: 18px 0 0;
|
||||||
|
min-height: 300px;
|
||||||
|
padding: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #141210;
|
||||||
|
color: #efe6d8;
|
||||||
|
font-family: "SFMono-Regular", Consolas, monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<section class="hero">
|
||||||
|
<h1>ChromeCard Proxy Portal</h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
Primary browser entry point for the current prototype. Browser traffic now targets k_proxy directly.
|
||||||
|
Enrollment, login, session reuse, counter access, and logout all happen on this TLS endpoint.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Enrollment</h2>
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input id="username" placeholder="alice" autocomplete="off">
|
||||||
|
<div class="actions">
|
||||||
|
<button id="enrollBtn">Enroll User</button>
|
||||||
|
<button id="checkBtn" class="secondary">Check Enrollment</button>
|
||||||
|
</div>
|
||||||
|
<div class="status">
|
||||||
|
<div>Stored username: <strong id="storedUser">none</strong></div>
|
||||||
|
<div>Session active: <strong id="sessionActive">no</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Session Flow</h2>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="loginBtn">Login</button>
|
||||||
|
<button id="statusBtn" class="secondary">Status</button>
|
||||||
|
<button id="counterBtn">Counter</button>
|
||||||
|
<button id="logoutBtn" class="secondary">Logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<pre id="log"></pre>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const USER_KEY = "chromecard.proxy.username";
|
||||||
|
const TOKEN_KEY = "chromecard.proxy.session_token";
|
||||||
|
const EXP_KEY = "chromecard.proxy.expires_at";
|
||||||
|
const logNode = document.getElementById("log");
|
||||||
|
const usernameNode = document.getElementById("username");
|
||||||
|
const storedUserNode = document.getElementById("storedUser");
|
||||||
|
const sessionActiveNode = document.getElementById("sessionActive");
|
||||||
|
|
||||||
|
function getStoredUser() { return localStorage.getItem(USER_KEY) || ""; }
|
||||||
|
function getStoredToken() { return localStorage.getItem(TOKEN_KEY) || ""; }
|
||||||
|
function syncState() {
|
||||||
|
const user = getStoredUser();
|
||||||
|
storedUserNode.textContent = user || "none";
|
||||||
|
sessionActiveNode.textContent = getStoredToken() ? "yes" : "no";
|
||||||
|
if (user && !usernameNode.value) {
|
||||||
|
usernameNode.value = user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function log(message, payload) {
|
||||||
|
const stamp = new Date().toLocaleTimeString();
|
||||||
|
let line = `[${stamp}] ${message}`;
|
||||||
|
if (payload !== undefined) {
|
||||||
|
line += "\\n" + JSON.stringify(payload, null, 2);
|
||||||
|
}
|
||||||
|
logNode.textContent = line + "\\n\\n" + logNode.textContent;
|
||||||
|
}
|
||||||
|
async function jsonRequest(method, path, payload, withToken = false) {
|
||||||
|
const headers = {"Content-Type": "application/json"};
|
||||||
|
if (withToken && getStoredToken()) {
|
||||||
|
headers["Authorization"] = "Bearer " + getStoredToken();
|
||||||
|
}
|
||||||
|
const resp = await fetch(path, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: payload === undefined ? undefined : JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("enrollBtn").addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const username = usernameNode.value.trim();
|
||||||
|
const data = await jsonRequest("POST", "/enroll/register", {username});
|
||||||
|
localStorage.setItem(USER_KEY, username);
|
||||||
|
syncState();
|
||||||
|
log("Enrollment updated", data);
|
||||||
|
} catch (err) {
|
||||||
|
log("Enrollment failed", {error: err.message});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("checkBtn").addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const username = usernameNode.value.trim() || getStoredUser();
|
||||||
|
const resp = await fetch("/enroll/status?username=" + encodeURIComponent(username));
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) throw new Error(JSON.stringify(data));
|
||||||
|
log("Enrollment status", data);
|
||||||
|
} catch (err) {
|
||||||
|
log("Enrollment status failed", {error: err.message});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("loginBtn").addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const username = usernameNode.value.trim() || getStoredUser();
|
||||||
|
const data = await jsonRequest("POST", "/session/login", {username});
|
||||||
|
localStorage.setItem(USER_KEY, username);
|
||||||
|
localStorage.setItem(TOKEN_KEY, data.session_token || "");
|
||||||
|
localStorage.setItem(EXP_KEY, String(data.expires_at || ""));
|
||||||
|
syncState();
|
||||||
|
log("Login ok", data);
|
||||||
|
} catch (err) {
|
||||||
|
log("Login failed", {error: err.message});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("statusBtn").addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const data = await jsonRequest("POST", "/session/status", {}, true);
|
||||||
|
log("Session status", data);
|
||||||
|
} catch (err) {
|
||||||
|
log("Status failed", {error: err.message});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("counterBtn").addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const data = await jsonRequest("POST", "/resource/counter", {}, true);
|
||||||
|
log("Counter response", data);
|
||||||
|
} catch (err) {
|
||||||
|
log("Counter failed", {error: err.message});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("logoutBtn").addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const data = await jsonRequest("POST", "/session/logout", {}, true);
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(EXP_KEY);
|
||||||
|
syncState();
|
||||||
|
log("Logout response", data);
|
||||||
|
} catch (err) {
|
||||||
|
log("Logout failed", {error: err.message});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
syncState();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Session:
|
class Session:
|
||||||
username: str
|
username: str
|
||||||
|
|
@ -194,6 +462,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _html(self, body: str) -> None:
|
||||||
|
data = body.encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
def _read_json(self) -> dict[str, Any]:
|
def _read_json(self) -> dict[str, Any]:
|
||||||
length = int(self.headers.get("Content-Length", "0"))
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
raw = self.rfile.read(length)
|
raw = self.rfile.read(length)
|
||||||
|
|
@ -221,6 +497,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
|
|
||||||
def do_GET(self) -> None: # noqa: N802
|
def do_GET(self) -> None: # noqa: N802
|
||||||
path = urlparse(self.path).path
|
path = urlparse(self.path).path
|
||||||
|
if path == "/":
|
||||||
|
self._html(HTML)
|
||||||
|
return
|
||||||
if path == "/health":
|
if path == "/health":
|
||||||
self._json(
|
self._json(
|
||||||
200,
|
200,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue