Spaces:
Paused
Paused
Persist submissions in JSON and add owner room
Browse files- .dockerignore +2 -0
- Dockerfile +2 -0
- plan.md +2 -39
- public/manus-routes.json +1 -1
- public/owner.html +27 -0
- public/owner.js +73 -0
- server.js +49 -12
.dockerignore
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
node_modules
|
| 2 |
.git
|
| 3 |
dist
|
|
|
|
|
|
|
| 4 |
.env
|
| 5 |
.env.*
|
| 6 |
!.env.example
|
|
|
|
| 1 |
node_modules
|
| 2 |
.git
|
| 3 |
dist
|
| 4 |
+
data
|
| 5 |
+
submissions.json
|
| 6 |
.env
|
| 7 |
.env.*
|
| 8 |
!.env.example
|
Dockerfile
CHANGED
|
@@ -10,6 +10,8 @@ RUN npm run build
|
|
| 10 |
|
| 11 |
ENV NODE_ENV=production
|
| 12 |
ENV PORT=7860
|
|
|
|
|
|
|
| 13 |
EXPOSE 7860
|
| 14 |
|
| 15 |
CMD ["node", "server.js"]
|
|
|
|
| 10 |
|
| 11 |
ENV NODE_ENV=production
|
| 12 |
ENV PORT=7860
|
| 13 |
+
ENV DATA_DIR=/data
|
| 14 |
+
RUN mkdir -p /data
|
| 15 |
EXPOSE 7860
|
| 16 |
|
| 17 |
CMD ["node", "server.js"]
|
plan.md
CHANGED
|
@@ -1,40 +1,3 @@
|
|
| 1 |
-
# Second Chance — implementation plan
|
| 2 |
|
| 3 |
-
##
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
## Design direction
|
| 7 |
-
- **Design movement:** editorial glassmorphism with soft 3D object art and “digital keepsake” romance.
|
| 8 |
-
- **Core principles:** emotional honesty over persuasion; layered depth; playful but grown-up microcopy; consent is visible and never implied.
|
| 9 |
-
- **Color philosophy:** parchment, cocoa, clay, and rosewood create a warmer, more intimate atmosphere; espresso typography keeps the page grounded; deep red is reserved for heart/affirmation moments and muted brass marks repair rather than perfection.
|
| 10 |
-
- **Layout paradigm:** an unfolding vertical story rather than a centered marketing grid. Oversized type, offset cards, sticky story markers, and wide breathing room create a scroll-led narrative.
|
| 11 |
-
- **Signature elements:** repaired floating heart, handwritten-style promise notes, thin chapter rail / progress marker.
|
| 12 |
-
- **Interaction philosophy:** each action should feel like a small act of sincerity: choose a promise, write a name, opt in explicitly, then let the other person decide.
|
| 13 |
-
- **Animation:** restrained parallax on background ribbons and heart, springy chips, soft card reveals, reduced-motion fallback, no confetti until acceptance.
|
| 14 |
-
- **Typography:** use a high-contrast serif display face for emotional headlines and a clean grotesk sans for UI/body. Use `Cormorant Garamond` + `Manrope` from Google Fonts.
|
| 15 |
-
- **Brand essence:** a private second-chance ritual for two people who want to try again honestly — tender, lucid, a little cheeky.
|
| 16 |
-
- **Brand voice:** intimate, direct, self-aware. Example lines: “A heart can open without forgetting.” / “No grand gesture. Just a clear yes, if it’s really a yes.”
|
| 17 |
-
- **Wordmark & logo:** `SC` monogram inside a small broken-and-rejoined ring, paired with the lowercase wordmark `second/chance`.
|
| 18 |
-
- **Signature brand color:** `#A51F2B` rosewood red.
|
| 19 |
-
|
| 20 |
-
## Implementation approach
|
| 21 |
-
- Vite + vanilla JavaScript for a small, dependency-light interactive page.
|
| 22 |
-
- `index.html` contains semantic sections and a single app shell.
|
| 23 |
-
- `src/main.js` owns step state, promise selection, name entry, consent gating, final decision, parallax listeners, and reduced-motion behavior.
|
| 24 |
-
- `src/styles.css` owns responsive editorial layout, glass effects, typography, responsive breakpoints, and animation.
|
| 25 |
-
- `public/manus-routes.json` declares the root route for Preview/publishing tooling.
|
| 26 |
-
- `public/second-chance-hero.png` will hold the generated 3D hero illustration.
|
| 27 |
-
|
| 28 |
-
## Project structure
|
| 29 |
-
- `index.html` — document shell, metadata, app mount.
|
| 30 |
-
- `src/main.js` — stateful interaction flow and progressive rendering.
|
| 31 |
-
- `src/styles.css` — all visual design and responsive behavior.
|
| 32 |
-
- `public/manus-routes.json` — route manifest.
|
| 33 |
-
- `app.config.ts` — project logo metadata.
|
| 34 |
-
- `TODO.md` — outcome clauses tracked for implementation.
|
| 35 |
-
|
| 36 |
-
## Reconnection journey update
|
| 37 |
-
The page is organized as four active chapters — notice the gap, rebuild the bridge, make it real, and leave room — with a sticky progress navigator, animated bridge dividers, scroll-aware active states, pointer-reactive hero art, and reduced-motion-aware transitions. Completing the third promise gently advances the user into the named apology chapter instead of leaving the form as a static page.
|
| 38 |
-
|
| 39 |
-
## Sincerity and privacy extension
|
| 40 |
-
The journey now collects a short accountable apology in addition to the promise bouquet. After consent, the server posts the bounded answer payload to OpenRouter using `cohere/north-mini-code:free` and asks for a nuanced, non-certainty-based sincerity impression. The UI labels it as an impression rather than a lie detector and shows a visibly rough editorial result card. If the provider rejects the configured credential or is unavailable, the endpoint returns a clearly labeled offline reflection instead of pretending a model ran. The exact answers and reading are sealed after submission and only revealed after the server checks the owner-only `OWNER_VIEW_PASSWORD` secret; neither secret is shipped to frontend code.
|
|
|
|
|
|
|
| 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 `/owner` dashboard and `/api/owner/submissions` endpoint provide cross-device access without a login, per request. This is intentionally private-by-URL rather than authenticated: anyone who knows the owner URL can read the records. A mounted persistent volume is required for the JSON file to survive Hugging Face container replacement.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public/manus-routes.json
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
{"routes":[{"path":"/","title":"Second Chance"}]}
|
|
|
|
| 1 |
+
{"routes":[{"path":"/","title":"Second Chance"},{"path":"/owner","title":"Private Room"}]}
|
public/owner.html
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<meta name="theme-color" content="#f6ede6" />
|
| 7 |
+
<title>second/chance — private room</title>
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 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>
|
| 16 |
+
<main>
|
| 17 |
+
<div class="top"><div class="brand">second<span>/</span>chance</div><a class="back" href="/">← back to the room</a></div>
|
| 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>Privacy note:</strong> this dashboard has no login, as requested. Anyone who knows this URL may be able to read these private answers. The JSON store is persistent only on the Space filesystem or mounted <code>/data</code> volume.</div>
|
| 22 |
+
<div class="toolbar"><span class="count" id="count">loading the room…</span><button class="button" id="refresh" type="button">refresh answers ↻</button></div>
|
| 23 |
+
<section class="grid" id="entries" aria-live="polite"></section>
|
| 24 |
+
</main>
|
| 25 |
+
<script src="/owner.js"></script>
|
| 26 |
+
</body>
|
| 27 |
+
</html>
|
public/owner.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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));
|
| 7 |
+
|
| 8 |
+
function addText(parent, tag, className, value) {
|
| 9 |
+
const node = document.createElement(tag);
|
| 10 |
+
node.className = className;
|
| 11 |
+
node.textContent = text(value);
|
| 12 |
+
parent.appendChild(node);
|
| 13 |
+
return node;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
function renderSubmission(item) {
|
| 17 |
+
const article = document.createElement('article');
|
| 18 |
+
article.className = 'entry';
|
| 19 |
+
const head = document.createElement('div');
|
| 20 |
+
head.className = 'entry-head';
|
| 21 |
+
addText(head, 'h2', '', item.name || 'Unnamed answer');
|
| 22 |
+
addText(head, 'time', '', formatDate(item.createdAt));
|
| 23 |
+
article.appendChild(head);
|
| 24 |
+
addText(article, 'div', 'label', 'the hard part');
|
| 25 |
+
addText(article, 'p', 'answer', item.apology);
|
| 26 |
+
addText(article, 'div', 'label', 'promises');
|
| 27 |
+
const pills = document.createElement('div');
|
| 28 |
+
pills.className = 'pills';
|
| 29 |
+
(Array.isArray(item.promises) ? item.promises : []).forEach((promise) => addText(pills, 'span', 'pill', promise));
|
| 30 |
+
article.appendChild(pills);
|
| 31 |
+
addText(article, 'div', 'label', 'consent');
|
| 32 |
+
addText(article, 'p', 'status', item.consent ? 'given — she said she means it and respects your choice' : 'not given');
|
| 33 |
+
const review = document.createElement('div');
|
| 34 |
+
review.className = 'review';
|
| 35 |
+
addText(review, 'div', 'label', 'imperfect AI reflection');
|
| 36 |
+
const score = document.createElement('div');
|
| 37 |
+
score.className = 'score';
|
| 38 |
+
score.textContent = `${item.reading?.score ?? '—'} / 100`;
|
| 39 |
+
const source = document.createElement('span');
|
| 40 |
+
source.className = 'source';
|
| 41 |
+
source.textContent = item.reading?.source === 'model' ? 'model-backed' : 'offline reflection';
|
| 42 |
+
score.appendChild(source);
|
| 43 |
+
review.appendChild(score);
|
| 44 |
+
addText(review, 'p', 'answer', item.reading?.read || 'No reading saved.');
|
| 45 |
+
addText(review, 'p', 'status', `${item.reading?.label || 'unlabeled'} · care signal: ${item.reading?.careSignal || 'not recorded'}`);
|
| 46 |
+
article.appendChild(review);
|
| 47 |
+
return article;
|
| 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', { cache: 'no-store' });
|
| 55 |
+
if (!response.ok) throw new Error('Could not load the room.');
|
| 56 |
+
const payload = await response.json();
|
| 57 |
+
entries.replaceChildren();
|
| 58 |
+
const list = Array.isArray(payload.submissions) ? payload.submissions : [];
|
| 59 |
+
count.textContent = `${list.length} sealed answer${list.length === 1 ? '' : 's'} · newest first`;
|
| 60 |
+
if (!list.length) {
|
| 61 |
+
addText(entries, 'div', 'empty', 'No one has answered yet. When she finishes the room, her words will appear here.');
|
| 62 |
+
} else {
|
| 63 |
+
list.forEach((item) => entries.appendChild(renderSubmission(item)));
|
| 64 |
+
}
|
| 65 |
+
} catch (error) {
|
| 66 |
+
count.textContent = error.message;
|
| 67 |
+
} finally {
|
| 68 |
+
refresh.disabled = false;
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
refresh.addEventListener('click', load);
|
| 73 |
+
load();
|
server.js
CHANGED
|
@@ -9,7 +9,26 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
| 9 |
const port = Number(process.env.PORT || 3000);
|
| 10 |
const isProduction = process.env.NODE_ENV === 'production';
|
| 11 |
const model = 'cohere/north-mini-code:free';
|
| 12 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
const fallbackReading = {
|
| 15 |
score: 58,
|
|
@@ -59,12 +78,8 @@ function localReflection(payload) {
|
|
| 59 |
return { ...fallbackReading, score: Math.max(35, Math.min(74, score)) };
|
| 60 |
}
|
| 61 |
|
| 62 |
-
function
|
| 63 |
-
|
| 64 |
-
for (const [id, submission] of submissions) {
|
| 65 |
-
if (submission.createdAt < expiry) submissions.delete(id);
|
| 66 |
-
}
|
| 67 |
-
while (submissions.size > 200) submissions.delete(submissions.keys().next().value);
|
| 68 |
}
|
| 69 |
|
| 70 |
async function rateSincerity(payload) {
|
|
@@ -109,12 +124,26 @@ async function readBody(req) {
|
|
| 109 |
}
|
| 110 |
|
| 111 |
async function handleApi(req, res) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
if (req.method === 'POST' && req.url === '/api/owner-unlock') {
|
| 113 |
try {
|
| 114 |
const payload = await readBody(req);
|
| 115 |
const expected = process.env.OWNER_VIEW_PASSWORD || '';
|
| 116 |
const supplied = cleanString(payload.password, 200);
|
| 117 |
-
const submission =
|
| 118 |
const matches = expected && supplied && Buffer.byteLength(expected) === Buffer.byteLength(supplied) && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(supplied));
|
| 119 |
if (!matches || !submission) {
|
| 120 |
sendJson(res, 401, { error: 'That key does not open this room.' });
|
|
@@ -157,8 +186,16 @@ async function handleApi(req, res) {
|
|
| 157 |
reading = localReflection(normalizedPayload);
|
| 158 |
}
|
| 159 |
const submissionId = crypto.randomUUID();
|
| 160 |
-
submissions.
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
sendJson(res, 200, { sealed: true, submissionId });
|
| 163 |
} catch (error) {
|
| 164 |
console.error('[sincerity]', error.message);
|
|
@@ -169,7 +206,7 @@ async function handleApi(req, res) {
|
|
| 169 |
|
| 170 |
function serveStatic(req, res) {
|
| 171 |
const requestPath = new URL(req.url, 'http://localhost').pathname;
|
| 172 |
-
const relative = requestPath === '/' ? 'index.html' : requestPath.replace(/^\//, '');
|
| 173 |
const filePath = path.resolve(__dirname, isProduction ? 'dist' : 'public', relative);
|
| 174 |
const basePath = path.resolve(__dirname, isProduction ? 'dist' : 'public');
|
| 175 |
if (!filePath.startsWith(basePath)) {
|
|
@@ -177,7 +214,7 @@ function serveStatic(req, res) {
|
|
| 177 |
}
|
| 178 |
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
| 179 |
const ext = path.extname(filePath);
|
| 180 |
-
const types = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.json': 'application/json' };
|
| 181 |
res.writeHead(200, { 'Content-Type': types[ext] || 'application/octet-stream' });
|
| 182 |
fs.createReadStream(filePath).pipe(res);
|
| 183 |
} else {
|
|
|
|
| 9 |
const port = Number(process.env.PORT || 3000);
|
| 10 |
const isProduction = process.env.NODE_ENV === 'production';
|
| 11 |
const model = 'cohere/north-mini-code:free';
|
| 12 |
+
const storageDir = process.env.DATA_DIR || (fs.existsSync('/data') ? '/data' : path.join(__dirname, 'data'));
|
| 13 |
+
const submissionsFile = path.join(storageDir, 'submissions.json');
|
| 14 |
+
|
| 15 |
+
function loadSubmissions() {
|
| 16 |
+
try {
|
| 17 |
+
const parsed = JSON.parse(fs.readFileSync(submissionsFile, 'utf8'));
|
| 18 |
+
return Array.isArray(parsed) ? parsed : [];
|
| 19 |
+
} catch {
|
| 20 |
+
return [];
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
let submissions = loadSubmissions();
|
| 25 |
+
|
| 26 |
+
function persistSubmissions() {
|
| 27 |
+
fs.mkdirSync(storageDir, { recursive: true });
|
| 28 |
+
const tempFile = `${submissionsFile}.tmp`;
|
| 29 |
+
fs.writeFileSync(tempFile, JSON.stringify(submissions, null, 2));
|
| 30 |
+
fs.renameSync(tempFile, submissionsFile);
|
| 31 |
+
}
|
| 32 |
|
| 33 |
const fallbackReading = {
|
| 34 |
score: 58,
|
|
|
|
| 78 |
return { ...fallbackReading, score: Math.max(35, Math.min(74, score)) };
|
| 79 |
}
|
| 80 |
|
| 81 |
+
function findSubmission(id) {
|
| 82 |
+
return submissions.find((submission) => submission.id === id);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
}
|
| 84 |
|
| 85 |
async function rateSincerity(payload) {
|
|
|
|
| 124 |
}
|
| 125 |
|
| 126 |
async function handleApi(req, res) {
|
| 127 |
+
if (req.method === 'GET' && req.url === '/api/owner/submissions') {
|
| 128 |
+
sendJson(res, 200, { submissions: [...submissions].sort((a, b) => b.createdAt - a.createdAt) });
|
| 129 |
+
return true;
|
| 130 |
+
}
|
| 131 |
+
if (req.method === 'GET' && req.url.startsWith('/api/owner/submissions/')) {
|
| 132 |
+
const id = cleanString(req.url.split('/').pop(), 100);
|
| 133 |
+
const submission = findSubmission(id);
|
| 134 |
+
if (!submission) {
|
| 135 |
+
sendJson(res, 404, { error: 'Submission not found.' });
|
| 136 |
+
return true;
|
| 137 |
+
}
|
| 138 |
+
sendJson(res, 200, { submission });
|
| 139 |
+
return true;
|
| 140 |
+
}
|
| 141 |
if (req.method === 'POST' && req.url === '/api/owner-unlock') {
|
| 142 |
try {
|
| 143 |
const payload = await readBody(req);
|
| 144 |
const expected = process.env.OWNER_VIEW_PASSWORD || '';
|
| 145 |
const supplied = cleanString(payload.password, 200);
|
| 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.' });
|
|
|
|
| 186 |
reading = localReflection(normalizedPayload);
|
| 187 |
}
|
| 188 |
const submissionId = crypto.randomUUID();
|
| 189 |
+
submissions.push({
|
| 190 |
+
id: submissionId,
|
| 191 |
+
...normalizedPayload,
|
| 192 |
+
reading,
|
| 193 |
+
createdAt: Date.now(),
|
| 194 |
+
viewed: false,
|
| 195 |
+
decision: 'pending',
|
| 196 |
+
updatedAt: Date.now(),
|
| 197 |
+
});
|
| 198 |
+
persistSubmissions();
|
| 199 |
sendJson(res, 200, { sealed: true, submissionId });
|
| 200 |
} catch (error) {
|
| 201 |
console.error('[sincerity]', error.message);
|
|
|
|
| 206 |
|
| 207 |
function serveStatic(req, res) {
|
| 208 |
const requestPath = new URL(req.url, 'http://localhost').pathname;
|
| 209 |
+
const relative = requestPath === '/' ? 'index.html' : requestPath === '/owner' ? 'owner.html' : requestPath.replace(/^\//, '');
|
| 210 |
const filePath = path.resolve(__dirname, isProduction ? 'dist' : 'public', relative);
|
| 211 |
const basePath = path.resolve(__dirname, isProduction ? 'dist' : 'public');
|
| 212 |
if (!filePath.startsWith(basePath)) {
|
|
|
|
| 214 |
}
|
| 215 |
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
| 216 |
const ext = path.extname(filePath);
|
| 217 |
+
const types = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.json': 'application/json', '.mp3': 'audio/mpeg' };
|
| 218 |
res.writeHead(200, { 'Content-Type': types[ext] || 'application/octet-stream' });
|
| 219 |
fs.createReadStream(filePath).pipe(res);
|
| 220 |
} else {
|