From 00b8339ca20275e5ebfa6e28a7a3a197a4157056 Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Tue, 6 Oct 2026 16:56:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(contact):=20=EB=AC=B8=EC=9D=98=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=EC=9D=84=20=EC=84=9C=EB=B2=84=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=EB=A1=9C=20=EC=98=AE=EA=B2=A8=20=ED=86=A0=ED=81=B0=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 브라우저로 내려간 값은 무엇이든 공개다. VITE_LEAD_TOKEN 이 번들에 평문으로 박혀 있었고(실제로 추출해 확인했다) 토큰은 보호 장치가 되지 못했다. 토큰을 새로 발급해도 똑같이 공개된다. - api/lead.js 신규. 토큰과 Apps Script 주소를 서버 환경변수(LEAD_TOKEN· LEAD_ENDPOINT)로만 들고 요청을 전달한다. VITE_ 접두사를 쓰지 않아 번들에 들어가지 않는다. - Origin 허용 목록으로 webforai.kr·www·aio2o.webforai.kr 만 받는다. 토큰과 달리 Origin 은 브라우저가 채우고 스크립트가 바꿀 수 없다. 다른 사이트에 폼을 심어 이 주소를 때리는 것을 막는다. aio2o 는 정적 배포라 자체 함수를 둘 수 없어 이 함수를 같이 쓴다(교차 출처라 CORS 를 명시). - submitLead 는 같은 출처 /api/lead 로 보낸다. 클라이언트 환경변수가 없어졌다. - vercel.json 의 전체 경로 리라이트에서 /api 를 제외한다. 안 그러면 함수 요청까지 index.html 로 삼킨다. Vercel 환경변수: LEAD_ENDPOINT·LEAD_TOKEN 추가, VITE_LEAD_* 2개 삭제. Vite 는 코드에서 안 쓰더라도 VITE_ 접두사 변수를 전부 번들에 넣기 때문에 삭제까지 해야 토큰이 사라진다(삭제 전 재배포에서 여전히 검출됐다). 검증: 운영 번들에서 토큰 0건·script.google.com 0건. /api/lead 정상 응답 {"ok":true,"mailed":true}. 다른 Origin 403. SPA 라우트(/discovery/:id· /actions/:id) 200 유지. 실제 폼 제출 접수 확인. Co-Authored-By: Claude Opus 5 (1M context) --- api/lead.js | 106 ++++++++++++++++++++++++++++++++++++++++++ src/lib/submitLead.ts | 37 +++++---------- vercel.json | 2 +- 3 files changed, 118 insertions(+), 27 deletions(-) create mode 100644 api/lead.js diff --git a/api/lead.js b/api/lead.js new file mode 100644 index 0000000..bb2a662 --- /dev/null +++ b/api/lead.js @@ -0,0 +1,106 @@ +/** + * 문의 폼 수신 프록시 (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; + } +} diff --git a/src/lib/submitLead.ts b/src/lib/submitLead.ts index 6ee5453..5f62d64 100644 --- a/src/lib/submitLead.ts +++ b/src/lib/submitLead.ts @@ -1,21 +1,14 @@ /** * 문의 폼 전송. * - * 받는 쪽은 Google Apps Script 웹앱이다(scripts/apps-script/lead-intake.gs). - * 그 스크립트 하나가 구글시트에 행을 추가하고 알림 메일까지 보낸다. - * 이 사이트는 서버가 없는 SPA 라 중간 서버를 두지 않았다. + * 같은 출처의 /api/lead 로 보낸다. 그 함수가 토큰을 붙여 Apps Script 로 넘기고, + * 스크립트가 구글시트에 행을 추가하고 알림 메일을 보낸다. * - * Apps Script 웹앱은 CORS 프리플라이트에 응답하지 않는다. 그래서 단순 요청(simple request) - * 조건을 지켜 Content-Type 을 text/plain 으로 보낸다. 스크립트가 본문을 JSON 으로 파싱한다. - * 헤더를 application/json 으로 바꾸면 프리플라이트가 발생해 브라우저에서 차단된다. + * 2026-10-01 이전에는 브라우저가 Apps Script 주소와 토큰을 직접 들고 호출했다. + * 브라우저로 내려간 값은 무엇이든 공개라 토큰이 번들에 평문으로 박혔다(실제로 추출해 확인). + * 토큰을 서버로 옮겨 번들에서 없앴고, 그 덕에 클라이언트 환경변수도 필요 없어졌다. */ -/** Vercel 환경변수 VITE_LEAD_ENDPOINT (Apps Script 배포 URL). 없으면 전송하지 않는다. */ -const ENDPOINT = import.meta.env.VITE_LEAD_ENDPOINT as string | undefined; - -/** 선택. 스크립트 쪽 SHARED_TOKEN 과 같은 값을 넣으면 외부의 무작위 호출을 걸러낸다. */ -const TOKEN = import.meta.env.VITE_LEAD_TOKEN as string | undefined; - export interface LeadInput { name: string; company: string; @@ -26,30 +19,22 @@ export interface LeadInput { source: string; /** * 허니팟. 사람에게는 보이지 않는 칸이라 항상 빈 문자열이어야 한다. - * 값이 차 있으면 스크립트가 봇으로 보고 저장하지 않는다. - * 이름은 Apps Script 의 HONEYPOT_FIELD 와 같아야 한다. + * 값이 차 있으면 Apps Script 가 봇으로 보고 저장하지 않는다. */ website: string; } -export type LeadResult = { ok: true } | { ok: false; reason: 'unconfigured' | 'failed' }; +export type LeadResult = { ok: true } | { ok: false; reason: 'failed' }; export async function submitLead(input: LeadInput): Promise { - if (!ENDPOINT) { - // 엔드포인트가 없는데 성공했다고 말하지 않는다. 화면은 메일 폴백을 보여준다. - console.warn('[submitLead] VITE_LEAD_ENDPOINT 가 설정되지 않았습니다.'); - return { ok: false, reason: 'unconfigured' }; - } try { - const res = await fetch(ENDPOINT, { + const res = await fetch('/api/lead', { method: 'POST', - // 프리플라이트를 피하기 위한 simple request. 위 주석 참고. - headers: { 'Content-Type': 'text/plain;charset=utf-8' }, - body: JSON.stringify({ ...input, token: TOKEN ?? '', submittedAt: new Date().toISOString() }), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), }); - if (!res.ok) return { ok: false, reason: 'failed' }; const data = await res.json().catch(() => null); - // 스크립트는 { ok: true } 를 돌려준다. 형식이 다르면 실패로 본다. + // 성공했다고 말할 근거가 없으면 실패로 둔다. 화면은 메일 폴백을 보여준다. return data && data.ok ? { ok: true } : { ok: false, reason: 'failed' }; } catch (e) { console.error('[submitLead] 전송 실패', e); diff --git a/vercel.json b/vercel.json index 0d199b2..52782d8 100644 --- a/vercel.json +++ b/vercel.json @@ -1,5 +1,5 @@ { "rewrites": [ - { "source": "/(.*)", "destination": "/index.html" } + { "source": "/((?!api/).*)", "destination": "/index.html" } ] }