@@ -2,19 +2,148 @@ const { randomBytes } = require('crypto');
22const { sql } = require ( './_lib/db' ) ;
33const { isAdmin } = require ( './_lib/admin-auth' ) ;
44
5+ // Escape HTML to prevent XSS in hidden form fields
6+ function esc ( s ) { return ( s || '' ) . replace ( / & / g, '&' ) . replace ( / " / g, '"' ) . replace ( / < / g, '<' ) . replace ( / > / g, '>' ) ; }
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+
576module . 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