Spaces:
Paused
Paused
Manus Agent commited on
Commit ·
5d4cb85
1
Parent(s): 2fca7e3
Persist and protect cross-browser answer viewing
Browse files- plan.md +1 -1
- public/owner.html +8 -3
- public/owner.js +28 -2
- server.js +30 -7
- src/main.js +23 -1
plan.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
|
| 2 |
## Persistent answer room
|
| 3 |
-
Submissions are written atomically to JSON at `DATA_DIR/submissions.json`, defaulting to `/data` when available and falling back to the project data directory locally. The
|
|
|
|
| 1 |
|
| 2 |
## Persistent answer room
|
| 3 |
+
Submissions are written atomically to JSON at `DATA_DIR/submissions.json`, defaulting to `/data` when available and falling back to the project data directory locally. The home page discovers the latest saved submission after refresh or in another browser, then asks for `OWNER_VIEW_PASSWORD` before returning the answer. The `/owner` dashboard uses the same password-protected API and keeps the key only in the current tab. A Hugging Face persistent storage volume mounted at `/data` is required for the JSON file to survive Space/container replacement; without it, answers still survive browser refreshes while the current container is running, but not a recreated container.
|
public/owner.html
CHANGED
|
@@ -9,7 +9,7 @@
|
|
| 9 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 10 |
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
| 11 |
<style>
|
| 12 |
-
:root{--ink:#2b1a16;--muted:#7a5b52;--paper:#f6ede6;--card:rgba(255,250,245,.72);--red:#a51f2b;--line:rgba(43,26,22,.16);--serif:'Cormorant Garamond',Georgia,serif;--sans:'Manrope',Arial,sans-serif;--mono:'DM Mono',monospace}*{box-sizing:border-box}body{margin:0;background:linear-gradient(140deg,#f2dfd2,#f0e0d4 52%,#d7b9a8);color:var(--ink);font-family:var(--sans)}main{width:min(1100px,calc(100% - 40px));margin:0 auto;padding:34px 0 80px}.top{display:flex;justify-content:space-between;align-items:center;margin-bottom:70px}.brand{font-weight:800;font-size:14px}.brand span{color:var(--red)}.back{font:10px var(--mono);text-transform:uppercase;color:var(--muted);text-decoration:none}.eyebrow,.meta{font:10px var(--mono);letter-spacing:.07em;text-transform:uppercase;color:var(--muted)}h1{font:600 clamp(60px,9vw,120px)/.82 var(--serif);letter-spacing:-.06em;margin:0 0 24px}h1 em{color:var(--red);font-weight:500}.lede{max-width:510px;color:var(--muted);line-height:1.7;margin-bottom:44px}.notice{border:1px solid var(--line);background:rgba(255,250,245,.46);padding:16px 20px;max-width:700px;font-size:12px;line-height:1.6;margin-bottom:28px}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:18px;margin:24px 0}.count{font:11px var(--mono);color:var(--muted);text-transform:uppercase}.button{border:0;border-radius:999px;background:var(--ink);color:white;padding:12px 18px;font-weight:800;cursor:pointer}.empty{padding:42px;border:1px dashed var(--line);color:var(--muted)}.grid{display:grid;gap:18px}.entry{background:var(--card);border:1px solid rgba(255,250,245,.82);box-shadow:12px 14px 0 rgba(164,91,79,.18);padding:24px;border-radius:2px 24px 2px 24px}.entry-head{display:flex;justify-content:space-between;gap:20px;align-items:start;border-bottom:1px solid var(--line);padding-bottom:15px;margin-bottom:18px}.entry h2{font:600 34px/.95 var(--serif);margin:0}.entry time{font:10px var(--mono);color:var(--muted);text-align:right}.label{font:10px var(--mono);text-transform:uppercase;color:var(--red);letter-spacing:.05em;margin:18px 0 7px}.answer{white-space:pre-wrap;line-height:1.65;font-size:14px;margin:0}.pills{display:flex;flex-wrap:wrap;gap:8px}.pill{font-size:12px;border:1px solid var(--line);padding:8px 10px;border-radius:999px;background:rgba(255,255,255,.3)}.review{border-left:3px solid var(--red);padding:13px 16px;background:rgba(165,31,43,.06);margin-top:8px}.score{font:700 22px var(--serif);color:var(--red)}.source{font:10px var(--mono);text-transform:uppercase;color:var(--muted);margin-left:8px}.status{font:10px var(--mono);text-transform:uppercase;color:var(--muted)}@media(max-width:600px){main{width:calc(100% - 28px);padding-top:20px}.top{margin-bottom:52px}.entry-head{display:block}.entry time{display:block;text-align:left;margin-top:10px}.toolbar{align-items:start;flex-direction:column}}
|
| 13 |
</style>
|
| 14 |
</head>
|
| 15 |
<body>
|
|
@@ -18,8 +18,13 @@
|
|
| 18 |
<p class="eyebrow">private room / no login enabled</p>
|
| 19 |
<h1>What she<br /><em>said.</em></h1>
|
| 20 |
<p class="lede">Every answer submitted through the journey is collected here, so you can read it from your own device after she finishes.</p>
|
| 21 |
-
<div class="notice"><strong>
|
| 22 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
<section class="grid" id="entries" aria-live="polite"></section>
|
| 24 |
</main>
|
| 25 |
<script src="/owner.js"></script>
|
|
|
|
| 9 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 10 |
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
| 11 |
<style>
|
| 12 |
+
:root{--ink:#2b1a16;--muted:#7a5b52;--paper:#f6ede6;--card:rgba(255,250,245,.72);--red:#a51f2b;--line:rgba(43,26,22,.16);--serif:'Cormorant Garamond',Georgia,serif;--sans:'Manrope',Arial,sans-serif;--mono:'DM Mono',monospace}*{box-sizing:border-box}body{margin:0;background:linear-gradient(140deg,#f2dfd2,#f0e0d4 52%,#d7b9a8);color:var(--ink);font-family:var(--sans)}main{width:min(1100px,calc(100% - 40px));margin:0 auto;padding:34px 0 80px}.top{display:flex;justify-content:space-between;align-items:center;margin-bottom:70px}.brand{font-weight:800;font-size:14px}.brand span{color:var(--red)}.back{font:10px var(--mono);text-transform:uppercase;color:var(--muted);text-decoration:none}.eyebrow,.meta{font:10px var(--mono);letter-spacing:.07em;text-transform:uppercase;color:var(--muted)}h1{font:600 clamp(60px,9vw,120px)/.82 var(--serif);letter-spacing:-.06em;margin:0 0 24px}h1 em{color:var(--red);font-weight:500}.lede{max-width:510px;color:var(--muted);line-height:1.7;margin-bottom:44px}.notice{border:1px solid var(--line);background:rgba(255,250,245,.46);padding:16px 20px;max-width:700px;font-size:12px;line-height:1.6;margin-bottom:28px}.owner-gate{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:22px 0 34px;max-width:700px}.owner-gate input{flex:1;min-width:220px;border:0;border-bottom:1px solid var(--ink);outline:0;background:transparent;color:var(--ink);padding:12px 0;font:12px var(--mono)}.gate-status{flex-basis:100%;font:10px var(--mono);text-transform:uppercase;color:var(--muted)}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:18px;margin:24px 0}.count{font:11px var(--mono);color:var(--muted);text-transform:uppercase}.button{border:0;border-radius:999px;background:var(--ink);color:white;padding:12px 18px;font-weight:800;cursor:pointer}.empty{padding:42px;border:1px dashed var(--line);color:var(--muted)}.grid{display:grid;gap:18px}.entry{background:var(--card);border:1px solid rgba(255,250,245,.82);box-shadow:12px 14px 0 rgba(164,91,79,.18);padding:24px;border-radius:2px 24px 2px 24px}.entry-head{display:flex;justify-content:space-between;gap:20px;align-items:start;border-bottom:1px solid var(--line);padding-bottom:15px;margin-bottom:18px}.entry h2{font:600 34px/.95 var(--serif);margin:0}.entry time{font:10px var(--mono);color:var(--muted);text-align:right}.label{font:10px var(--mono);text-transform:uppercase;color:var(--red);letter-spacing:.05em;margin:18px 0 7px}.answer{white-space:pre-wrap;line-height:1.65;font-size:14px;margin:0}.pills{display:flex;flex-wrap:wrap;gap:8px}.pill{font-size:12px;border:1px solid var(--line);padding:8px 10px;border-radius:999px;background:rgba(255,255,255,.3)}.review{border-left:3px solid var(--red);padding:13px 16px;background:rgba(165,31,43,.06);margin-top:8px}.score{font:700 22px var(--serif);color:var(--red)}.source{font:10px var(--mono);text-transform:uppercase;color:var(--muted);margin-left:8px}.status{font:10px var(--mono);text-transform:uppercase;color:var(--muted)}@media(max-width:600px){main{width:calc(100% - 28px);padding-top:20px}.top{margin-bottom:52px}.entry-head{display:block}.entry time{display:block;text-align:left;margin-top:10px}.toolbar{align-items:start;flex-direction:column}.owner-gate{align-items:stretch;flex-direction:column}.owner-gate input{min-width:0;width:100%}}
|
| 13 |
</style>
|
| 14 |
</head>
|
| 15 |
<body>
|
|
|
|
| 18 |
<p class="eyebrow">private room / no login enabled</p>
|
| 19 |
<h1>What she<br /><em>said.</em></h1>
|
| 20 |
<p class="lede">Every answer submitted through the journey is collected here, so you can read it from your own device after she finishes.</p>
|
| 21 |
+
<div class="notice"><strong>Private room:</strong> enter the owner key to view saved answers. They are stored in the Space’s persistent JSON store at <code>/data/submissions.json</code>.</div>
|
| 22 |
+
<form class="owner-gate" id="ownerGate">
|
| 23 |
+
<input id="ownerPassword" type="password" autocomplete="current-password" placeholder="owner key" maxlength="200" required />
|
| 24 |
+
<button class="button" type="submit">view answers →</button>
|
| 25 |
+
<span class="gate-status" id="gateStatus" role="status">the key stays in this browser tab</span>
|
| 26 |
+
</form>
|
| 27 |
+
<div class="toolbar" hidden><span class="count" id="count">loading the room…</span><button class="button" id="refresh" type="button">refresh answers ↻</button></div>
|
| 28 |
<section class="grid" id="entries" aria-live="polite"></section>
|
| 29 |
</main>
|
| 30 |
<script src="/owner.js"></script>
|
public/owner.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
| 1 |
const entries = document.querySelector('#entries');
|
| 2 |
const count = document.querySelector('#count');
|
| 3 |
const refresh = document.querySelector('#refresh');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
const text = (value) => String(value ?? '');
|
| 6 |
const formatDate = (value) => new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value));
|
|
@@ -48,10 +53,16 @@ function renderSubmission(item) {
|
|
| 48 |
}
|
| 49 |
|
| 50 |
async function load() {
|
|
|
|
| 51 |
count.textContent = 'loading the room…';
|
| 52 |
refresh.disabled = true;
|
| 53 |
try {
|
| 54 |
-
const response = await fetch('/api/owner/submissions', {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
if (!response.ok) throw new Error('Could not load the room.');
|
| 56 |
const payload = await response.json();
|
| 57 |
entries.replaceChildren();
|
|
@@ -69,5 +80,20 @@ async function load() {
|
|
| 69 |
}
|
| 70 |
}
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
refresh.addEventListener('click', load);
|
| 73 |
-
load();
|
|
|
|
| 1 |
const entries = document.querySelector('#entries');
|
| 2 |
const count = document.querySelector('#count');
|
| 3 |
const refresh = document.querySelector('#refresh');
|
| 4 |
+
const ownerGate = document.querySelector('#ownerGate');
|
| 5 |
+
const ownerPassword = document.querySelector('#ownerPassword');
|
| 6 |
+
const gateStatus = document.querySelector('#gateStatus');
|
| 7 |
+
const toolbar = document.querySelector('.toolbar');
|
| 8 |
+
let password = '';
|
| 9 |
|
| 10 |
const text = (value) => String(value ?? '');
|
| 11 |
const formatDate = (value) => new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value));
|
|
|
|
| 53 |
}
|
| 54 |
|
| 55 |
async function load() {
|
| 56 |
+
if (!password) return;
|
| 57 |
count.textContent = 'loading the room…';
|
| 58 |
refresh.disabled = true;
|
| 59 |
try {
|
| 60 |
+
const response = await fetch('/api/owner/submissions', {
|
| 61 |
+
method: 'POST',
|
| 62 |
+
headers: { 'Content-Type': 'application/json' },
|
| 63 |
+
cache: 'no-store',
|
| 64 |
+
body: JSON.stringify({ password }),
|
| 65 |
+
});
|
| 66 |
if (!response.ok) throw new Error('Could not load the room.');
|
| 67 |
const payload = await response.json();
|
| 68 |
entries.replaceChildren();
|
|
|
|
| 80 |
}
|
| 81 |
}
|
| 82 |
|
| 83 |
+
ownerGate.addEventListener('submit', async (event) => {
|
| 84 |
+
event.preventDefault();
|
| 85 |
+
password = ownerPassword.value;
|
| 86 |
+
gateStatus.textContent = 'checking the key…';
|
| 87 |
+
try {
|
| 88 |
+
await load();
|
| 89 |
+
if (!entries.children.length) throw new Error('That key did not open the room.');
|
| 90 |
+
ownerGate.hidden = true;
|
| 91 |
+
toolbar.hidden = false;
|
| 92 |
+
} catch (error) {
|
| 93 |
+
password = '';
|
| 94 |
+
gateStatus.textContent = error.message;
|
| 95 |
+
ownerPassword.select();
|
| 96 |
+
}
|
| 97 |
+
});
|
| 98 |
+
|
| 99 |
refresh.addEventListener('click', load);
|
|
|
server.js
CHANGED
|
@@ -82,6 +82,17 @@ function findSubmission(id) {
|
|
| 82 |
return submissions.find((submission) => submission.id === id);
|
| 83 |
}
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
async function rateSincerity(payload) {
|
| 86 |
const apiKey = process.env.OPENROUTER_API_KEY;
|
| 87 |
if (!apiKey) return localReflection(payload);
|
|
@@ -124,8 +135,23 @@ async function readBody(req) {
|
|
| 124 |
}
|
| 125 |
|
| 126 |
async function handleApi(req, res) {
|
| 127 |
-
if (req.method === 'GET' && req.url === '/api/
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
return true;
|
| 130 |
}
|
| 131 |
if (req.method === 'GET' && req.url.startsWith('/api/owner/submissions/')) {
|
|
@@ -141,11 +167,8 @@ async function handleApi(req, res) {
|
|
| 141 |
if (req.method === 'POST' && req.url === '/api/owner-unlock') {
|
| 142 |
try {
|
| 143 |
const payload = await readBody(req);
|
| 144 |
-
const
|
| 145 |
-
|
| 146 |
-
const submission = findSubmission(cleanString(payload.submissionId, 100));
|
| 147 |
-
const matches = expected && supplied && Buffer.byteLength(expected) === Buffer.byteLength(supplied) && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(supplied));
|
| 148 |
-
if (!matches || !submission) {
|
| 149 |
sendJson(res, 401, { error: 'That key does not open this room.' });
|
| 150 |
return true;
|
| 151 |
}
|
|
|
|
| 82 |
return submissions.find((submission) => submission.id === id);
|
| 83 |
}
|
| 84 |
|
| 85 |
+
function latestSubmission() {
|
| 86 |
+
return [...submissions].sort((a, b) => b.createdAt - a.createdAt)[0];
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
function passwordMatches(supplied) {
|
| 90 |
+
const expected = process.env.OWNER_VIEW_PASSWORD || '';
|
| 91 |
+
const value = cleanString(supplied, 200);
|
| 92 |
+
return Boolean(expected && value && Buffer.byteLength(expected) === Buffer.byteLength(value)
|
| 93 |
+
&& crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(value)));
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
async function rateSincerity(payload) {
|
| 97 |
const apiKey = process.env.OPENROUTER_API_KEY;
|
| 98 |
if (!apiKey) return localReflection(payload);
|
|
|
|
| 135 |
}
|
| 136 |
|
| 137 |
async function handleApi(req, res) {
|
| 138 |
+
if (req.method === 'GET' && req.url === '/api/latest-submission') {
|
| 139 |
+
const latest = latestSubmission();
|
| 140 |
+
sendJson(res, 200, latest ? { available: true, submissionId: latest.id } : { available: false });
|
| 141 |
+
return true;
|
| 142 |
+
}
|
| 143 |
+
if (req.method === 'POST' && req.url === '/api/owner/submissions') {
|
| 144 |
+
try {
|
| 145 |
+
const payload = await readBody(req);
|
| 146 |
+
if (!passwordMatches(payload.password)) {
|
| 147 |
+
sendJson(res, 401, { error: 'That key does not open this room.' });
|
| 148 |
+
return true;
|
| 149 |
+
}
|
| 150 |
+
sendJson(res, 200, { submissions: [...submissions].sort((a, b) => b.createdAt - a.createdAt) });
|
| 151 |
+
} catch (error) {
|
| 152 |
+
console.error('[owner-submissions]', error.message);
|
| 153 |
+
sendJson(res, 400, { error: 'The private key could not be checked.' });
|
| 154 |
+
}
|
| 155 |
return true;
|
| 156 |
}
|
| 157 |
if (req.method === 'GET' && req.url.startsWith('/api/owner/submissions/')) {
|
|
|
|
| 167 |
if (req.method === 'POST' && req.url === '/api/owner-unlock') {
|
| 168 |
try {
|
| 169 |
const payload = await readBody(req);
|
| 170 |
+
const submission = findSubmission(cleanString(payload.submissionId, 100)) || latestSubmission();
|
| 171 |
+
if (!passwordMatches(payload.password) || !submission) {
|
|
|
|
|
|
|
|
|
|
| 172 |
sendJson(res, 401, { error: 'That key does not open this room.' });
|
| 173 |
return true;
|
| 174 |
}
|
src/main.js
CHANGED
|
@@ -222,7 +222,7 @@ function renderOwnerLock() {
|
|
| 222 |
const title = document.createElement('strong');
|
| 223 |
title.textContent = 'sealed for his eyes';
|
| 224 |
const copy = document.createElement('p');
|
| 225 |
-
copy.textContent = 'Her exact words and the sincerity read are tucked away. Enter the owner key to
|
| 226 |
const row = document.createElement('div');
|
| 227 |
row.className = 'private-key-row';
|
| 228 |
const input = document.createElement('input');
|
|
@@ -346,6 +346,26 @@ async function requestSincerity() {
|
|
| 346 |
}
|
| 347 |
}
|
| 348 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
function relockDecision() {
|
| 350 |
state.unlocked = false;
|
| 351 |
decisionStatus.textContent = 'locked until it’s honest';
|
|
@@ -436,6 +456,7 @@ sincerityResult.addEventListener('click', async (event) => {
|
|
| 436 |
apologyInput.value = state.apology;
|
| 437 |
state.sincerity = submission.reading;
|
| 438 |
state.ownerUnlocked = true;
|
|
|
|
| 439 |
setAnswerFieldsDisabled(true);
|
| 440 |
renderBouquet();
|
| 441 |
renderSincerity(state.sincerity);
|
|
@@ -533,3 +554,4 @@ const observer = new IntersectionObserver((entries) => {
|
|
| 533 |
$$('.reveal').forEach((el) => observer.observe(el));
|
| 534 |
|
| 535 |
renderBouquet();
|
|
|
|
|
|
| 222 |
const title = document.createElement('strong');
|
| 223 |
title.textContent = 'sealed for his eyes';
|
| 224 |
const copy = document.createElement('p');
|
| 225 |
+
copy.textContent = 'Her exact words and the sincerity read are tucked away. Enter the owner key to view the answer.';
|
| 226 |
const row = document.createElement('div');
|
| 227 |
row.className = 'private-key-row';
|
| 228 |
const input = document.createElement('input');
|
|
|
|
| 346 |
}
|
| 347 |
}
|
| 348 |
|
| 349 |
+
async function restorePersistedAnswer() {
|
| 350 |
+
try {
|
| 351 |
+
const response = await fetch('/api/latest-submission', { cache: 'no-store' });
|
| 352 |
+
if (!response.ok) return;
|
| 353 |
+
const payload = await response.json();
|
| 354 |
+
if (!payload.available || !payload.submissionId) return;
|
| 355 |
+
state.submissionId = payload.submissionId;
|
| 356 |
+
state.sincerity = { sealed: true };
|
| 357 |
+
state.sealed = true;
|
| 358 |
+
state.ownerUnlocked = false;
|
| 359 |
+
setAnswerFieldsDisabled(true);
|
| 360 |
+
$('#name').classList.add('is-sealed');
|
| 361 |
+
$('#promises').classList.add('is-sealed');
|
| 362 |
+
renderSincerity(state.sincerity);
|
| 363 |
+
updateUnlockState();
|
| 364 |
+
} catch {
|
| 365 |
+
// A temporary discovery failure should not prevent a new answer from being written.
|
| 366 |
+
}
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
function relockDecision() {
|
| 370 |
state.unlocked = false;
|
| 371 |
decisionStatus.textContent = 'locked until it’s honest';
|
|
|
|
| 456 |
apologyInput.value = state.apology;
|
| 457 |
state.sincerity = submission.reading;
|
| 458 |
state.ownerUnlocked = true;
|
| 459 |
+
consentCheck.checked = true;
|
| 460 |
setAnswerFieldsDisabled(true);
|
| 461 |
renderBouquet();
|
| 462 |
renderSincerity(state.sincerity);
|
|
|
|
| 554 |
$$('.reveal').forEach((el) => observer.observe(el));
|
| 555 |
|
| 556 |
renderBouquet();
|
| 557 |
+
restorePersistedAnswer();
|