/** * 데모 화면의 데이터베이스 조회·쓰기 (Vercel 서버 함수). POST { op, params } → { data, error } * * 왜 두는가 * 브라우저가 공개 키(anon)로 테이블을 직접 읽고 쓰던 구조라, 공개 키를 가진 누구나 병원 리포트를 내려받고 * 빌드 큐·병원 입력값을 쓸 수 있었다 (2026-10-07 확인). 조회·쓰기를 이 함수로 옮기고 익명 규칙(RLS)을 지운다. * docs/security/2026-10-07_rls_anon_lockdown.sql * * 원칙 * - 서비스 키(SUPABASE_SERVICE_ROLE_KEY)는 서버 환경변수에만 둔다. * - 임의 쿼리를 받지 않는다. 아래 OPS 에 적힌 작업만, 정해진 열·조건으로 실행한다. * - 출처 검사(api/_origin.js): infinith-demo·미리보기·localhost 만. 데모는 Vercel 배포 보호 뒤에 있다. */ import { createClient } from '@supabase/supabase-js'; import { guard, safeParse } from './_origin.js'; const ID = /^[A-Za-z0-9_-]{1,80}$/; const INPUT_KEYS = new Set([ 'author', 'editor', 'sponsorship', 'quote_items', 'recovery', 'diet_guide', 'revision_policy', 'discrepancy', 'specialty_doctors', 'domain', 'post_review', 'post_approval', 'report_approval', 'image_review', ]); const MAX_VALUE_BYTES = 50_000; /** 화면마다 읽는 빌드 열. 브라우저가 열 이름을 정하지 않는다. */ const BUILD_FIELDS = { status: 'status', inputs: 'id,status,phase,preview_url,posts,report,updated_at', image: 'id,status,preview_url,report,updated_at', site: 'id,clinic_id,clinic_name,status,phase,phases,posts,preview_url,error,updated_at', }; class BadRequest extends Error {} const need = (cond, msg) => { if (!cond) throw new BadRequest(msg); }; const id = (v, name = 'id') => { need(typeof v === 'string' && ID.test(v), `bad ${name}`); return v; }; const uuidish = (v, name = 'id') => { need(typeof v === 'string' && /^[0-9a-f-]{8,64}$/i.test(v), `bad ${name}`); return v; }; const obj = (v, name) => { need(v && typeof v === 'object' && !Array.isArray(v), `bad ${name}`); need(JSON.stringify(v).length <= MAX_VALUE_BYTES, `${name} too large`); return v; }; /** 작업 목록. 각 작업은 supabase 결과({ data, error })를 돌려준다. */ const OPS = { // 리포트 (src/lib/supabase.ts) 'report.byId': (sb, p) => sb.from('marketing_reports').select('*').eq('id', uuidish(p.id)).single(), 'report.status': (sb, p) => sb.from('marketing_reports').select('id, status, clinic_name, channel_data, report').eq('id', uuidish(p.id)).single(), // 마케팅 플랜 (src/hooks/useMarketingPlan.ts, src/lib/supabase.ts) 'run.clinicId': (sb, p) => sb.from('analysis_runs').select('clinic_id').eq('id', uuidish(p.id)).single(), 'clinic.byId': (sb, p) => sb.from('clinics').select('name, name_en, url').eq('id', uuidish(p.id)).single(), 'plan.active': (sb, p) => sb.from('content_plans').select('*').eq('clinic_id', uuidish(p.clinicId, 'clinicId')) .eq('is_active', true).order('created_at', { ascending: false }).limit(1).single(), 'plan.updateEntry': async (sb, p) => { const planId = uuidish(p.planId, 'planId'); need(typeof p.entryId === 'string' && p.entryId.length <= 120, 'bad entryId'); const updates = obj(p.updates, 'updates'); const { data: plan, error } = await sb.from('content_plans').select('calendar').eq('id', planId).single(); if (error || !plan) return { data: null, error: error ?? { message: 'plan not found' } }; for (const week of plan.calendar?.weeks ?? []) { for (let i = 0; i < (week.entries ?? []).length; i++) { if (week.entries[i].id === p.entryId) week.entries[i] = { ...week.entries[i], ...updates, isManualEdit: true }; } } return sb.from('content_plans').update({ calendar: plan.calendar }).eq('id', planId); }, // 전략 조정 (src/components/plan/StrategyAdjustmentSection.tsx) 'perf.latest': (sb, p) => sb.from('performance_metrics').select('kpi_progress, strategy_suggestions') .eq('clinic_id', uuidish(p.clinicId, 'clinicId')).order('created_at', { ascending: false }).limit(1).single(), 'adjustments.list': (sb, p) => sb.from('strategy_adjustments').select('id, adjustment_type, description, reason, created_at') .eq('clinic_id', uuidish(p.clinicId, 'clinicId')).order('created_at', { ascending: false }).limit(10), // 서포터즈 빌드·병원 입력 (discovery 화면들) 'builds.latest': (sb, p) => { const fields = BUILD_FIELDS[p.fields]; need(fields, 'bad fields'); return sb.from('supporter_builds').select(fields).eq('clinic_id', id(p.clinicId, 'clinicId')) .order('created_at', { ascending: false }).limit(1); }, 'builds.queue': (sb, p) => { need(typeof p.url === 'string' && /^https?:\/\/[^\s]{3,500}$/.test(p.url), 'bad url'); need(p.clinicName == null || (typeof p.clinicName === 'string' && p.clinicName.length <= 200), 'bad clinicName'); return sb.from('supporter_builds').insert({ clinic_id: id(p.clinicId, 'clinicId'), clinic_name: p.clinicName ?? null, url: p.url, status: 'queued' }); }, 'inputs.list': (sb, p) => { const clinicId = id(p.clinicId, 'clinicId'); if (p.key != null) { need(INPUT_KEYS.has(p.key), 'bad key'); return sb.from('supporter_inputs').select('value,input_by,created_at').eq('clinic_id', clinicId).eq('key', p.key) .order('created_at', { ascending: true }); } return sb.from('supporter_inputs').select('key,post_id,value,input_by,created_at').eq('clinic_id', clinicId) .order('created_at', { ascending: true }); }, 'inputs.add': (sb, p) => { need(INPUT_KEYS.has(p.key), 'bad key'); need(p.postId == null || (typeof p.postId === 'string' && p.postId.length <= 200), 'bad postId'); need(p.inputBy == null || (typeof p.inputBy === 'string' && p.inputBy.length <= 100), 'bad inputBy'); return sb.from('supporter_inputs').insert({ clinic_id: id(p.clinicId, 'clinicId'), key: p.key, post_id: p.postId ?? null, value: obj(p.value, 'value'), input_by: p.inputBy || null, }); }, }; export default async function handler(req, res) { if (guard(req, res, ['POST'])) return; const url = process.env.SUPABASE_URL ?? process.env.VITE_SUPABASE_URL; const key = process.env.SUPABASE_SERVICE_ROLE_KEY; if (!url || !key) { console.error('[api/db] SUPABASE_URL 또는 SUPABASE_SERVICE_ROLE_KEY 가 설정되지 않았습니다.'); return res.status(500).json({ data: null, error: { message: 'unconfigured' } }); } const body = typeof req.body === 'string' ? safeParse(req.body) : req.body ?? {}; const op = OPS[body?.op]; if (!op) return res.status(400).json({ data: null, error: { message: 'unknown op' } }); try { const sb = createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false } }); const { data, error } = await op(sb, body.params ?? {}); // 조회 실패·행 없음은 화면이 지금처럼 처리하도록 그대로 넘긴다. 상세 원인은 서버 로그에만 남긴다. if (error) console.error(`[api/db] ${body.op} 실패`, error.code ?? '', error.message ?? ''); return res.status(200).json({ data: data ?? null, error: error ? { message: error.message ?? 'error', code: error.code ?? null } : null }); } catch (e) { if (e instanceof BadRequest) return res.status(400).json({ data: null, error: { message: e.message } }); console.error(`[api/db] ${body.op} 예외`, e); return res.status(500).json({ data: null, error: { message: 'server error' } }); } }