미리보기는 Vercel 로그인 뒤에 있어 팀원만 연다. Referer 확인도 출처 단위로 비교한다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
90 lines
4.1 KiB
JavaScript
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;
|
|
}
|
|
}
|