/** * 문의 폼 수신 프록시 (Vercel 서버리스 함수). * * 왜 두는가 * 전에는 브라우저가 Apps Script 주소와 토큰을 직접 들고 호출했다. 브라우저로 내려간 값은 * 무엇이든 공개라 토큰이 번들에 평문으로 박혔고(실제로 추출해서 확인했다), 보호 장치가 * 되지 못했다. 이제 토큰은 이 함수의 서버 환경변수에만 둔다. 번들에는 들어가지 않는다. * * 무엇을 더 막는가 * 토큰과 달리 Origin 은 브라우저가 채우고 스크립트가 바꿀 수 없다. 허용 목록에 없는 * 곳에서 온 요청은 여기서 끊는다. 다른 사이트에 폼을 심어 이 주소를 때리는 것을 막는다. * (curl 처럼 브라우저가 아닌 호출은 Origin 이 없다. 그건 Apps Script 쪽 속도 제한이 받는다.) * * 환경변수 (Vercel 프로젝트 설정, 서버 전용 — VITE_ 접두사를 붙이지 않는다) * LEAD_ENDPOINT Apps Script 웹앱 /exec 주소 * LEAD_TOKEN 스크립트의 SHARED_TOKEN 과 같은 값 */ /** 이 주소들에서 온 요청만 받는다. aio2o 사이트는 정적 배포라 함수를 둘 수 없어 여기를 같이 쓴다. */ const ALLOWED_ORIGINS = [ 'https://webforai.kr', 'https://www.webforai.kr', 'https://aio2o.webforai.kr', ]; export default async function handler(req, res) { const origin = req.headers.origin || ''; const allowed = ALLOWED_ORIGINS.includes(origin); // 교차 출처(aio2o)에서 부르므로 CORS 를 명시한다. 허용 목록에 없으면 헤더를 주지 않는다. if (allowed) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary', 'Origin'); res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); res.setHeader('Access-Control-Max-Age', '86400'); } if (req.method === 'OPTIONS') { return res.status(allowed ? 204 : 403).end(); } if (req.method !== 'POST') { return res.status(405).json({ ok: false, error: 'method not allowed' }); } // Origin 이 있는데 목록에 없으면 다른 사이트에서 부른 것이다. 비어 있는 경우(브라우저가 아닌 // 호출)는 여기서 판단하지 않고 통과시킨다. Apps Script 의 속도 제한이 상한을 건다. if (origin && !allowed) { return res.status(403).json({ ok: false, error: 'forbidden origin' }); } const endpoint = process.env.LEAD_ENDPOINT; const token = process.env.LEAD_TOKEN ?? ''; if (!endpoint) { // 설정이 없는데 접수된 척하지 않는다. 화면이 메일 폴백을 띄우게 실패로 돌려준다. console.error('[api/lead] LEAD_ENDPOINT 가 설정되지 않았습니다.'); return res.status(500).json({ ok: false, error: 'unconfigured' }); } const body = typeof req.body === 'string' ? safeParse(req.body) : req.body ?? {}; if (!body || typeof body !== 'object') { return res.status(400).json({ ok: false, error: 'bad body' }); } if (!String(body.name ?? '').trim()) { return res.status(400).json({ ok: false, error: 'name required' }); } try { // Apps Script 는 CORS 프리플라이트에 응답하지 않지만, 서버 대 서버 호출이라 상관없다. // 리다이렉트를 따라가야 실제 응답(JSON)을 받는다. const upstream = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'text/plain;charset=utf-8' }, body: JSON.stringify({ name: body.name, company: body.company ?? '', phone: body.phone ?? '', email: body.email ?? '', message: body.message ?? '', // 어느 사이트에서 왔는지 시트에 남긴다. 값은 클라이언트가 보내지만 참고용이라 그대로 둔다. source: body.source ?? '', website: body.website ?? '', // 허니팟. 스크립트가 보고 걸러낸다. token, submittedAt: new Date().toISOString(), }), redirect: 'follow', }); const text = await upstream.text(); const data = safeParse(text); if (!upstream.ok || !data || !data.ok) { console.error('[api/lead] 업스트림 실패', upstream.status, text.slice(0, 200)); return res.status(502).json({ ok: false, error: 'upstream failed' }); } return res.status(200).json({ ok: true, mailed: data.mailed ?? null }); } catch (e) { console.error('[api/lead] 전달 실패', e); return res.status(502).json({ ok: false, error: 'upstream error' }); } } function safeParse(s) { try { return JSON.parse(s); } catch { return null; } }