o2o-infinith-demo/api/db.js
Haewon Kam 0d1fdd3430 docs: 익명 키 정책 정리 SQL 을 쓰기 정책까지 포함한 실행본으로 바꾼다
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:12:34 +09:00

131 lines
7.3 KiB
JavaScript

/**
* 데모 화면의 데이터베이스 조회·쓰기 (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' } });
}
}