Skip to content

Commit e86e203

Browse files
authored
Merge pull request #29 from sdamico/sdamico/backport-systemic-changes
Multi-deck build system, live-reload dev server, reusable CSS components
2 parents 1100443 + 8f81c48 commit e86e203

12 files changed

Lines changed: 852 additions & 118 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
.vercel
22
node_modules
3+
# Built deck output (generated by build.js)
34
content/page.html
45
.env
56
.env.*

api/page.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ const { join } = require('path');
33
const { getSession } = require('./_lib/auth');
44

55
module.exports = async (req, res) => {
6-
const session = await getSession(req);
6+
const isDev = process.env.VERCEL_ENV !== 'production' && !process.env.VERCEL;
7+
const session = isDev ? { email: 'dev@localhost' } : await getSession(req);
78

89
if (!session) {
910
res.writeHead(302, { Location: '/login.html' });

api/verify.js

Lines changed: 142 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,148 @@ const { randomBytes } = require('crypto');
22
const { sql } = require('./_lib/db');
33
const { isAdmin } = require('./_lib/admin-auth');
44

5+
// Escape HTML to prevent XSS in hidden form fields
6+
function esc(s) { return (s || '').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
7+
8+
// Landing page shown on GET — scanner-safe (token is NOT consumed until POST)
9+
function renderLanding(token, next, invite) {
10+
return `<!DOCTYPE html>
11+
<html lang="en">
12+
<head>
13+
<meta charset="UTF-8">
14+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
15+
<meta name="robots" content="noindex, nofollow">
16+
<title>AMERICAN NAIL — Verify</title>
17+
<style>
18+
@font-face { font-family: 'Inter'; src: url('/fonts/Inter-Regular.woff2') format('woff2'); font-weight: 400; }
19+
@font-face { font-family: 'JetBrains Mono'; src: url('/fonts/JetBrainsMono-Latin.woff2') format('woff2'); font-weight: 300; }
20+
* { margin: 0; padding: 0; box-sizing: border-box; }
21+
body {
22+
background: #111111;
23+
color: #ECEEE2;
24+
font-family: 'Inter', sans-serif;
25+
display: flex;
26+
align-items: center;
27+
justify-content: center;
28+
min-height: 100vh;
29+
}
30+
.box { width: 100%; max-width: 360px; padding: 0 24px; text-align: center; }
31+
.wordmark {
32+
display: block;
33+
margin: 0 auto 48px;
34+
font-family: 'Inter', sans-serif;
35+
font-weight: 700;
36+
font-size: 36px;
37+
letter-spacing: 0.1em;
38+
color: #E85D2C;
39+
opacity: 0.6;
40+
}
41+
h2 { font-size: 18px; font-weight: 400; margin-bottom: 12px; }
42+
p { font-size: 14px; color: rgba(236,238,226,0.5); line-height: 1.6; margin-bottom: 24px; }
43+
button {
44+
width: 100%;
45+
padding: 14px;
46+
background: rgba(232,93,44,0.12);
47+
border: 1px solid rgba(232,93,44,0.3);
48+
border-radius: 6px;
49+
color: #E85D2C;
50+
font-family: 'JetBrains Mono', monospace;
51+
font-size: 12px;
52+
letter-spacing: 1.5px;
53+
text-transform: uppercase;
54+
cursor: pointer;
55+
transition: background 0.2s, border-color 0.2s;
56+
}
57+
button:hover { background: rgba(232,93,44,0.2); border-color: rgba(232,93,44,0.5); }
58+
</style>
59+
</head>
60+
<body>
61+
<div class="box">
62+
<div class="wordmark">AMERICAN NAIL</div>
63+
<h2>Open your deck</h2>
64+
<p>Click below to continue to the pitch deck.</p>
65+
<form method="POST" action="/api/verify">
66+
<input type="hidden" name="token" value="${esc(token)}">
67+
<input type="hidden" name="next" value="${esc(next)}">
68+
<input type="hidden" name="invite" value="${esc(invite)}">
69+
<button type="submit">Continue to deck</button>
70+
</form>
71+
</div>
72+
</body>
73+
</html>`;
74+
}
75+
576
module.exports = async (req, res) => {
6-
const url = new URL(req.url, `http://${req.headers.host}`);
7-
const token = url.searchParams.get('token');
8-
const next = url.searchParams.get('next');
9-
const inviteCode = url.searchParams.get('invite');
77+
// --- GET: show landing page (scanner-safe, does NOT consume token) ---
78+
if (req.method === 'GET') {
79+
const url = new URL(req.url, `http://${req.headers.host}`);
80+
const token = url.searchParams.get('token');
81+
const next = url.searchParams.get('next') || '';
82+
const invite = url.searchParams.get('invite') || '';
1083

11-
if (!token) {
12-
res.writeHead(302, { Location: '/login.html' });
13-
res.end();
84+
if (!token) {
85+
res.writeHead(302, { Location: '/login.html' });
86+
res.end();
87+
return;
88+
}
89+
90+
try {
91+
// Check token is valid (but do NOT consume it)
92+
const { rows } = await sql`
93+
SELECT id FROM magic_tokens
94+
WHERE token = ${token} AND used_at IS NULL AND expires_at > NOW()
95+
`;
96+
97+
if (rows.length === 0) {
98+
res.writeHead(302, { Location: '/login.html?expired=1' });
99+
res.end();
100+
return;
101+
}
102+
103+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
104+
res.end(renderLanding(token, next, invite));
105+
} catch (e) {
106+
console.error('Verify GET error:', e);
107+
res.writeHead(302, { Location: '/login.html' });
108+
res.end();
109+
}
110+
return;
111+
}
112+
113+
// --- POST: consume token and create session ---
114+
if (req.method !== 'POST') {
115+
res.writeHead(405);
116+
res.end('Method not allowed');
14117
return;
15118
}
16119

17120
try {
121+
// Parse URL-encoded form body
122+
let body;
123+
if (req.body) {
124+
body = typeof req.body === 'string' ? req.body : String(req.body);
125+
} else {
126+
body = await new Promise((resolve, reject) => {
127+
let buf = '';
128+
req.on('data', chunk => {
129+
buf += chunk;
130+
if (buf.length > 4096) reject(new Error('Body too large'));
131+
});
132+
req.on('end', () => resolve(buf));
133+
});
134+
}
135+
136+
const params = new URLSearchParams(body);
137+
const token = params.get('token');
138+
const next = params.get('next') || '';
139+
const inviteCode = params.get('invite') || '';
140+
141+
if (!token) {
142+
res.writeHead(302, { Location: '/login.html' });
143+
res.end();
144+
return;
145+
}
146+
18147
// Atomically find and consume valid, unused, non-expired token (prevents TOCTOU race)
19148
const { rows } = await sql`
20149
UPDATE magic_tokens
@@ -24,7 +153,6 @@ module.exports = async (req, res) => {
24153
`;
25154

26155
if (rows.length === 0) {
27-
// Token invalid, already used, or expired — redirect to login with error hint
28156
res.writeHead(302, { Location: '/login.html?expired=1' });
29157
res.end();
30158
return;
@@ -53,15 +181,13 @@ module.exports = async (req, res) => {
53181
`;
54182
if (links.length > 0) {
55183
const link = links[0];
56-
// Grant data_room_access only if invite has grant_dr=true and user doesn't already have access
57184
if (link.grant_dr !== false) {
58185
await sql`
59186
INSERT INTO data_room_access (email, granted_by, view_id)
60187
VALUES (${email}, ${'invite'}, ${link.view_id})
61188
ON CONFLICT (email) DO NOTHING
62189
`;
63190
}
64-
// Add allow rule so they can log in again independently (skip if exists)
65191
const { rows: existing } = await sql`
66192
SELECT id FROM email_rules
67193
WHERE LOWER(pattern) = ${email} AND rule_type = 'allow'
@@ -100,7 +226,12 @@ module.exports = async (req, res) => {
100226
res.writeHead(302, { Location: redirect });
101227
res.end();
102228
} catch (e) {
103-
console.error('Verify error:', e);
229+
if (e.message === 'Body too large') {
230+
res.writeHead(413);
231+
res.end('Request too large');
232+
return;
233+
}
234+
console.error('Verify POST error:', e);
104235
res.writeHead(302, { Location: '/login.html' });
105236
res.end();
106237
}

0 commit comments

Comments
 (0)