/** * 콘텐츠 스튜디오 서버 공용 모듈 (Vercel 서버 함수 전용, '_' 로 시작해 주소로 열리지 않는다). * * 왜 서버에 두는가 * Gemini·Creatomate 키는 브라우저로 내려가면 공개다. 2026-10-07 공개 번들에서 Gemini 키가 발견됐고, * 새 Gemini 키는 서비스 계정 바인딩 키라 웹사이트(리퍼러) 제한을 걸 수 없다. 그래서 호출을 서버로 옮긴다. * 브라우저는 채널·포맷·주제 선택값만 보내고, 프롬프트와 영상 구성은 여기서 만든다. * 임의의 프롬프트·렌더를 대신 실행해 주는 통로가 되지 않도록 선택값은 아래 목록에 있는 것만 받는다. * * 채널·포맷 목록은 src/types/studio.ts 의 CHANNEL_OPTIONS 와 같아야 한다 (서버 함수는 src 의 TS 를 불러올 수 없다). */ export const CHANNELS = { youtube: { label: 'YouTube', formats: { shorts: ['Shorts', '9:16'], long_form: ['Long-form', '16:9'] } }, instagram: { label: 'Instagram', formats: { reels: ['Reels', '9:16'], carousel: ['Carousel', '1:1'], feed_image: ['Feed Image', '1:1'], stories: ['Stories', '9:16'] }, }, naver_blog: { label: 'Naver Blog', formats: { seo_post: ['SEO Post', '16:9'], faq_post: ['FAQ Post', '16:9'] } }, tiktok: { label: 'TikTok', formats: { short_video: ['Short Video', '9:16'] } }, facebook: { label: 'Facebook', formats: { ad_creative: ['Ad Creative', '1:1'], retarget_content: ['Retarget Content', '16:9'] } }, }; export const PILLARS = ['safety', 'expertise', 'results', 'care']; /** 이 주소들에서 온 요청만 받는다. 키를 쓰는 유료 API 라 Origin 이 없는 요청(브라우저 밖 호출)도 받지 않는다. */ const ALLOWED_ORIGINS = [ 'https://infinith-demo.vercel.app', 'http://localhost:3000', 'http://127.0.0.1:3000', ]; /** 이 프로젝트의 미리보기 배포 주소. 미리보기는 Vercel 로그인(배포 보호) 뒤에 있어 팀원만 연다. */ const PREVIEW_ORIGIN = /^https:\/\/infinith-demo-[a-z0-9]+-o2odev-5530s-projects\.vercel\.app$/; const isAllowedOrigin = (o) => ALLOWED_ORIGINS.includes(o) || PREVIEW_ORIGIN.test(o); /** * CORS·메서드·출처를 확인한다. 통과하면 null, 아니면 이미 응답을 보낸 뒤 true 를 돌려준다. * @param {string[]} methods 허용 메서드 */ export function guard(req, res, methods) { const origin = req.headers.origin || ''; const allowed = isAllowedOrigin(origin); if (allowed) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary', 'Origin'); res.setHeader('Access-Control-Allow-Methods', [...methods, 'OPTIONS'].join(', ')); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); } if (req.method === 'OPTIONS') { res.status(allowed ? 204 : 403).end(); return true; } if (!methods.includes(req.method)) { res.status(405).json({ ok: false, error: 'method not allowed' }); return true; } // 같은 출처 GET 은 브라우저가 Origin 을 붙이지 않을 수 있어 Referer 로도 확인한다. const referer = req.headers.referer || ''; const refOrigin = (referer.match(/^https?:\/\/[^/]+/) || [''])[0]; const sameSite = isAllowedOrigin(refOrigin); if (!allowed && !sameSite) { res.status(403).json({ ok: false, error: 'forbidden origin' }); return true; } return null; } /** * 브라우저가 보낸 선택값을 목록과 대조한다. 목록 밖이면 null. * @returns {{ channel: string, channelLabel: string, format: string, formatLabel: string, aspectRatio: string, pillarId: string | null } | null} */ export function readSelection(body) { const b = typeof body === 'string' ? safeParse(body) : body ?? {}; const ch = CHANNELS[b?.channel]; const fmt = ch?.formats[b?.format]; if (!ch || !fmt) return null; const pillarId = b?.pillarId == null ? null : String(b.pillarId); if (pillarId !== null && !PILLARS.includes(pillarId)) return null; return { channel: b.channel, channelLabel: ch.label, format: b.format, formatLabel: fmt[0], aspectRatio: fmt[1], pillarId }; } export function safeParse(s) { try { return JSON.parse(s); } catch { return null; } }