o2o-infinith-demo/api/_studio.js
Haewon Kam 506f594a4e fix: 데모 미리보기 배포 주소에서도 스튜디오 서버 함수를 부를 수 있게 한다
미리보기는 Vercel 로그인 뒤에 있어 팀원만 연다. Referer 확인도 출처 단위로 비교한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:47:09 +09:00

90 lines
4.1 KiB
JavaScript

/**
* 콘텐츠 스튜디오 서버 공용 모듈 (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;
}
}