From 0be730f0f9a3186f88433bb3cfcfa08200fcfbf6 Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Wed, 7 Oct 2026 16:09:29 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EB=8D=B0=EB=AA=A8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=9D=98=20DB=20=EC=A1=B0=ED=9A=8C=C2=B7=EC=93=B0?= =?UTF-8?q?=EA=B8=B0=EB=A5=BC=20=EC=84=9C=EB=B2=84=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=EB=A1=9C=20=EC=98=AE=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 브라우저가 공개 키로 테이블을 직접 읽고 쓰던 구조를 없앤다. - api/db.js: 정해진 작업만 서비스 키로 실행 (임의 쿼리·열 지정 불가) - api/_origin.js: 서버 함수 공용 출처 검사, 공개 배포에서는 404 - src/lib/db.ts: 화면용 호출 함수, 반환 형태 { data, error } 유지 - 빌드 큐에 바로 넣던 예전 진단 신청 모달 삭제 Co-Authored-By: Claude Opus 5.5 --- api/_origin.js | 59 +++++ api/_studio.js | 55 +---- api/db.js | 130 ++++++++++ docs/WEBFORAI_FEATURE_FLAGS.md | 4 +- src/components/ContactModal.tsx | 4 +- src/components/discovery/BuildPlanSection.tsx | 13 +- .../discovery/ClinicInputsPanel.tsx | 8 +- .../discovery/DiscoveryLeadModal.tsx | 227 ------------------ src/components/discovery/ImageReviewPanel.tsx | 12 +- .../plan/StrategyAdjustmentSection.tsx | 18 +- src/hooks/useMarketingPlan.ts | 15 +- src/lib/db.ts | 31 +++ src/lib/supabase.ts | 57 +---- src/pages/SiteBuildPage.tsx | 9 +- 14 files changed, 264 insertions(+), 378 deletions(-) create mode 100644 api/_origin.js create mode 100644 api/db.js delete mode 100644 src/components/discovery/DiscoveryLeadModal.tsx create mode 100644 src/lib/db.ts diff --git a/api/_origin.js b/api/_origin.js new file mode 100644 index 0000000..9a8c774 --- /dev/null +++ b/api/_origin.js @@ -0,0 +1,59 @@ +/** + * 서버 함수 공용 출처 검사 (Vercel 서버 함수 전용, '_' 로 시작해 주소로 열리지 않는다). + * + * infinith-demo 와 그 미리보기, 로컬 개발에서 온 요청만 받는다. 데모는 Vercel 배포 보호(팀원 로그인) 뒤에 있어 + * 서버 함수도 로그인한 팀원만 부를 수 있다. 공개 배포(webforai.kr, VITE_SITE=discovery)에서는 내부 함수를 열지 않는다. + */ + +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) { + if (process.env.VITE_SITE === 'discovery') { + res.status(404).json({ ok: false, error: 'not found' }); + return true; + } + 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]; + if (!allowed && !isAllowedOrigin(refOrigin)) { + res.status(403).json({ ok: false, error: 'forbidden origin' }); + return true; + } + return null; +} + +export function safeParse(s) { + try { + return JSON.parse(s); + } catch { + return null; + } +} diff --git a/api/_studio.js b/api/_studio.js index 67d5bb3..a0fd7e1 100644 --- a/api/_studio.js +++ b/api/_studio.js @@ -10,6 +10,10 @@ * 채널·포맷 목록은 src/types/studio.ts 의 CHANNEL_OPTIONS 와 같아야 한다 (서버 함수는 src 의 TS 를 불러올 수 없다). */ +import { safeParse } from './_origin.js'; + +export { guard } from './_origin.js'; + export const CHANNELS = { youtube: { label: 'YouTube', formats: { shorts: ['Shorts', '9:16'], long_form: ['Long-form', '16:9'] } }, instagram: { @@ -23,49 +27,6 @@ export const CHANNELS = { 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} @@ -79,11 +40,3 @@ export function readSelection(body) { 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; - } -} diff --git a/api/db.js b/api/db.js new file mode 100644 index 0000000..557428a --- /dev/null +++ b/api/db.js @@ -0,0 +1,130 @@ +/** + * 데모 화면의 데이터베이스 조회·쓰기 (Vercel 서버 함수). POST { op, params } → { data, error } + * + * 왜 두는가 + * 브라우저가 공개 키(anon)로 테이블을 직접 읽고 쓰던 구조라, 공개 키를 가진 누구나 병원 리포트를 내려받고 + * 빌드 큐·병원 입력값을 쓸 수 있었다 (2026-10-07 확인). 조회·쓰기를 이 함수로 옮기고 익명 규칙(RLS)을 지운다. + * docs/security/2026-10-07_rls_anon_read_DRAFT.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' } }); + } +} diff --git a/docs/WEBFORAI_FEATURE_FLAGS.md b/docs/WEBFORAI_FEATURE_FLAGS.md index e42d25f..ff8f95f 100644 --- a/docs/WEBFORAI_FEATURE_FLAGS.md +++ b/docs/WEBFORAI_FEATURE_FLAGS.md @@ -40,6 +40,6 @@ infinith-demo 배포(VITE_SITE 없음)는 모든 화면이 그대로 열린다. 공개 배포에서 열려면 아래가 먼저 끝나야 한다. 스위치를 만들지 않은 이유다. 1. 로그인(Supabase Auth)과 병원별 접근 권한 -2. 데이터베이스 익명 읽기 정리: `docs/security/2026-10-07_rls_anon_read_DRAFT.sql` (infinith-demo 화면 영향 확인 후 실행) +2. 데이터베이스 익명 읽기·쓰기 정리: 2026-10-07 실행 (`docs/security/2026-10-07_rls_anon_read_DRAFT.sql`). 데모 화면은 서버 함수 `api/db.js` 로 조회한다 3. `src/lib/supabase.ts` 가 공개 번들에 들어가게 되므로 번들 검사 `secrets` 의 Supabase 항목을 어떻게 다룰지 결정 -4. 진단 신청 모달(`DiscoveryLeadModal.tsx`, 현재 미사용)은 신청마다 자동 빌드 큐(`supporter_builds`)에 넣는다. 공개 폼에 다시 붙이려면 사람 승인 단계를 먼저 둔다. +4. 예전 진단 신청 모달(신청마다 자동 빌드 큐 `supporter_builds` 에 넣던 것)은 2026-10-07 삭제했다. 빌드 큐 쓰기는 이제 서버 함수(`api/db.js` 의 `builds.queue`)만 할 수 있다. 공개 폼에서 빌드를 받으려면 사람 승인 단계를 먼저 둔다. diff --git a/src/components/ContactModal.tsx b/src/components/ContactModal.tsx index a356449..6d0cf05 100644 --- a/src/components/ContactModal.tsx +++ b/src/components/ContactModal.tsx @@ -3,9 +3,7 @@ * * - 이름·회사·전화·이메일·문의내용 → Apps Script 웹앱 → 구글시트 적재 + 알림 메일 * - 전송 실패 시 성공한 척하지 않고 mailto 폴백을 보여준다 (정직성 규약). - * DiscoveryLeadModal 과 같은 규칙이다. - * - 화면 구조·색·버튼은 DiscoveryLeadModal 을 그대로 따른다. 같은 사이트에서 모달이 - * 두 종류로 보이지 않게 하기 위해서다. + * - 공개 사이트의 문의·진단 요청은 모두 이 모달 하나로 받는다 (예전 진단 신청 모달은 2026-10-07 삭제). */ import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; diff --git a/src/components/discovery/BuildPlanSection.tsx b/src/components/discovery/BuildPlanSection.tsx index 1789206..2a7d15d 100644 --- a/src/components/discovery/BuildPlanSection.tsx +++ b/src/components/discovery/BuildPlanSection.tsx @@ -11,7 +11,7 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router'; import { motion } from 'motion/react'; -import { supabase } from '../../lib/supabase'; +import { db } from '../../lib/db'; import { STATE_LABEL } from '../../lib/buildPhases'; import type { BuildState } from '../../lib/buildPhases'; import { weakSpots } from '../../lib/buildCoverage'; @@ -39,8 +39,7 @@ export function BuildPlanSection({ result, overall, summary }: Props) { useEffect(() => { let alive = true; void (async () => { - const { data } = await supabase.from('supporter_builds').select('status') - .eq('clinic_id', result.id).order('created_at', { ascending: false }).limit(1); + const { data } = await db>('builds.latest', { clinicId: result.id, fields: 'status' }); if (!alive) return; setExisting((data?.[0]?.status as BuildState) ?? null); setChecking(false); @@ -50,13 +49,11 @@ export function BuildPlanSection({ result, overall, summary }: Props) { async function approve() { setBusy(true); setError(null); - await supabase.from('supporter_inputs').insert({ - clinic_id: result.id, key: 'report_approval', + await db('inputs.add', { + clinicId: result.id, key: 'report_approval', value: { ...summary, weakSpots: weak.map((w) => w.id), approvedAt: new Date().toISOString() }, }); - const { error: e } = await supabase.from('supporter_builds').insert({ - clinic_id: result.id, clinic_name: result.clinicName, url: result.url, status: 'queued', - }); + const { error: e } = await db('builds.queue', { clinicId: result.id, clinicName: result.clinicName, url: result.url }); setBusy(false); if (e) { setError(e.message); return; } navigate(`/build/${result.id}`); diff --git a/src/components/discovery/ClinicInputsPanel.tsx b/src/components/discovery/ClinicInputsPanel.tsx index 233b3ab..ab28cb1 100644 --- a/src/components/discovery/ClinicInputsPanel.tsx +++ b/src/components/discovery/ClinicInputsPanel.tsx @@ -7,7 +7,7 @@ * - 의학 검토·게시 승인은 사람 게이트다(§3-7). 후보만 보여주고 확정은 병원이 입력한다. */ import { useEffect, useMemo, useState } from 'react'; -import { supabase } from '../../lib/supabase'; +import { db } from '../../lib/db'; type Build = { id: string; @@ -61,8 +61,8 @@ export function ClinicInputsPanel({ clinicId }: { clinicId: string }) { let alive = true; (async () => { const [b, i] = await Promise.all([ - supabase.from('supporter_builds').select('id,status,phase,preview_url,posts,report,updated_at').eq('clinic_id', clinicId).order('created_at', { ascending: false }).limit(1), - supabase.from('supporter_inputs').select('key,post_id,value,input_by,created_at').eq('clinic_id', clinicId).order('created_at', { ascending: true }), + db('builds.latest', { clinicId, fields: 'inputs' }), + db('inputs.list', { clinicId }), ]); if (!alive) return; if (b.error || i.error) { setLoadError((b.error ?? i.error)?.message ?? '불러오기 실패'); return; } @@ -82,7 +82,7 @@ export function ClinicInputsPanel({ clinicId }: { clinicId: string }) { async function save(key: string, value: Record, postId?: string) { setBusy(`${key}|${postId ?? ''}`); setMsg(null); - const { error } = await supabase.from('supporter_inputs').insert({ clinic_id: clinicId, key, post_id: postId ?? null, value, input_by: inputBy || null }); + const { error } = await db('inputs.add', { clinicId, key, postId: postId ?? null, value, inputBy: inputBy || null }); setBusy(null); if (error) { setMsg(`저장 실패: ${error.message}`); return; } setRows((prev) => [...prev, { key, post_id: postId ?? null, value, input_by: inputBy || null, created_at: new Date().toISOString() }]); diff --git a/src/components/discovery/DiscoveryLeadModal.tsx b/src/components/discovery/DiscoveryLeadModal.tsx deleted file mode 100644 index 2c334ef..0000000 --- a/src/components/discovery/DiscoveryLeadModal.tsx +++ /dev/null @@ -1,227 +0,0 @@ -/** - * "URL 입력으로 시작하기" 진단 신청 모달. - * - * - 병원 URL(필수) + 회신 연락처(필수) + 상호(선택) → Supabase discovery_leads insert - * - RLS는 anon insert만 허용 (조회는 service_role 전용) - * - 저장 실패 시 성공한 척하지 않고 mailto 폴백을 보여준다 (정직성 규약) - */ -import { useEffect, useState } from 'react'; -import { AnimatePresence, motion } from 'motion/react'; -import { ArrowRight } from 'lucide-react'; -import { supabase } from '../../lib/supabase'; -import { DISCOVERY_CONTACT_EMAIL } from './discoveryContact'; - -interface DiscoveryLeadModalProps { - open: boolean; - onClose: () => void; -} - -type SubmitState = 'idle' | 'submitting' | 'done' | 'error'; - -/** 스킴이 없으면 https://를 붙이고, 호스트에 점이 있어야 유효로 본다 */ -const normalizeUrl = (raw: string): string | null => { - const trimmed = raw.trim(); - if (!trimmed) return null; - const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; - try { - const u = new URL(withScheme); - if (!u.hostname.includes('.')) return null; - return u.toString(); - } catch { - return null; - } -}; - -export default function DiscoveryLeadModal({ open, onClose }: DiscoveryLeadModalProps) { - const [url, setUrl] = useState(''); - const [clinicName, setClinicName] = useState(''); - const [contact, setContact] = useState(''); - const [state, setState] = useState('idle'); - const [fieldError, setFieldError] = useState(null); - - useEffect(() => { - if (!open) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [open, onClose]); - - // @types/react 미설치 프로젝트 방침이라 React.FormEvent 대신 구조적 타입 사용 - const handleSubmit = async (e: { preventDefault: () => void }) => { - e.preventDefault(); - const normalized = normalizeUrl(url); - if (!normalized) { - setFieldError('URL 형식을 확인해 주세요. 예: viewclinic.com'); - return; - } - if (!contact.trim()) { - setFieldError('진단 결과를 받을 이메일 또는 전화번호를 입력해 주세요.'); - return; - } - setFieldError(null); - setState('submitting'); - // discovery_leads 는 anon 조회 정책이 없어 insert 반환값을 읽지 않는다. - const { error } = await supabase.from('discovery_leads').insert({ - url: normalized, - clinic_name: clinicName.trim() || null, - contact: contact.trim(), - source: 'discovery_landing', - }); - // 서포터즈 자동 빌드 큐(supporter_builds, status=queued). 워커(workers/supporters-build/poll.mjs)가 집어 간다. - // clinic_id 는 홈페이지 호스트에서 만든다 (www.k-wonjin.co.kr → k-wonjin). 큐 삽입이 실패해도 신청 자체는 접수된 것으로 본다. - if (!error) { - const host = (() => { try { return new URL(normalized).host; } catch { return normalized; } })(); - const clinicId = host.replace(/^www\./, '').split('.')[0].toLowerCase().replace(/[^a-z0-9-]/g, '') || 'clinic'; - await supabase.from('supporter_builds').insert({ clinic_id: clinicId, url: normalized, status: 'queued', clinic_name: clinicName.trim() || null }); - } - setState(error ? 'error' : 'done'); - }; - - const fallbackMailto = () => { - const body = `병원/업체 URL: ${url}\n상호: ${clinicName || '-'}\n회신 연락처: ${contact}`; - return `mailto:${DISCOVERY_CONTACT_EMAIL}?subject=${encodeURIComponent( - '[INFINITH AI Discovery] 무료 진단 신청', - )}&body=${encodeURIComponent(body)}`; - }; - - return ( - - {open && ( - - e.stopPropagation()} - role="dialog" - aria-modal="true" - aria-label="무료 AEO/GEO 진단 신청" - > - {/* 닫기 (커스텀 filled SVG — 라인 아이콘 금지 규칙) */} - - - {state === 'done' ? ( -
-
- -
-

신청이 접수되었습니다

-

- 입력하신 URL을 실측 진단한 뒤, 남겨주신 연락처로 리포트를 안내드립니다. -
- 문의: {DISCOVERY_CONTACT_EMAIL} -

- -
- ) : ( -
-

URL 입력으로 시작하기

-

- 병원 홈페이지 URL만 입력하면 AI 검색 인용 가능성을 실측 진단합니다. 진단은 - 무료입니다. -

- - - setUrl(e.target.value)} - placeholder="viewclinic.com" - autoFocus - className="w-full h-12 px-4 mb-4 rounded-xl border border-slate-200 bg-[linear-gradient(to_right,#fff3eb,#e4cfff,#f5f9ff)] text-[15px] text-primary-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-[#6C5CE7]/40" - /> - - - setClinicName(e.target.value)} - placeholder="뷰성형외과" - className="on-light w-full h-12 px-4 mb-4 rounded-xl border border-slate-200 bg-white text-[15px] text-primary-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-[#6C5CE7]/40" - /> - - - setContact(e.target.value)} - placeholder="marketing@clinic.com" - className="on-light w-full h-12 px-4 mb-4 rounded-xl border border-slate-200 bg-white text-[15px] text-primary-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-[#6C5CE7]/40" - /> - - {fieldError && ( -

- {fieldError} -

- )} - - {state === 'error' && ( -

- 저장에 실패했습니다. 아래 이메일로 직접 신청해 주세요. -
- - {DISCOVERY_CONTACT_EMAIL}로 신청 메일 보내기 - -

- )} - - - -

- 입력 정보는 진단 회신 용도로만 사용합니다. -

-
- )} -
-
- )} -
- ); -} diff --git a/src/components/discovery/ImageReviewPanel.tsx b/src/components/discovery/ImageReviewPanel.tsx index 6864e1b..07d7617 100644 --- a/src/components/discovery/ImageReviewPanel.tsx +++ b/src/components/discovery/ImageReviewPanel.tsx @@ -7,7 +7,7 @@ */ import { useEffect, useMemo, useState } from 'react'; import { motion, AnimatePresence } from 'motion/react'; -import { supabase } from '../../lib/supabase'; +import { db } from '../../lib/db'; import { CheckFilled, CrossFilled, EyeFilled, WarningFilled, LinkExternalFilled } from '../icons/FilledIcons'; type Role = 'hero' | 'used' | 'stored'; @@ -67,8 +67,8 @@ export function ImageReviewPanel({ clinicId }: { clinicId: string }) { let alive = true; void (async () => { const [b, inputs] = await Promise.all([ - supabase.from('supporter_builds').select('id,status,preview_url,report,updated_at').eq('clinic_id', clinicId).order('created_at', { ascending: false }).limit(1), - supabase.from('supporter_inputs').select('value,input_by,created_at').eq('clinic_id', clinicId).eq('key', 'image_review').order('created_at', { ascending: true }), + db('builds.latest', { clinicId, fields: 'image' }), + db; input_by: string | null; created_at: string }>>('inputs.list', { clinicId, key: 'image_review' }), ]); if (!alive) return; if (b.error) setLoadError(b.error.message); @@ -103,11 +103,11 @@ export function ImageReviewPanel({ clinicId }: { clinicId: string }) { async function save() { setSaving(true); setSaveError(null); - const { error } = await supabase.from('supporter_inputs').insert({ - clinic_id: clinicId, + const { error } = await db('inputs.add', { + clinicId, key: 'image_review', value: { decisions, excludeImages: excluded.map((i) => i.id), total: items.length, decidedAt: new Date().toISOString() }, - input_by: inputBy.trim() || null, + inputBy: inputBy.trim() || null, }); setSaving(false); if (error) { setSaveError(error.message); return; } diff --git a/src/components/plan/StrategyAdjustmentSection.tsx b/src/components/plan/StrategyAdjustmentSection.tsx index 1e7ea68..dfe8f04 100644 --- a/src/components/plan/StrategyAdjustmentSection.tsx +++ b/src/components/plan/StrategyAdjustmentSection.tsx @@ -2,7 +2,8 @@ import { useState, useEffect } from 'react'; import { motion } from 'motion/react'; import { SectionWrapper } from '../report/ui/SectionWrapper'; import { MegaphoneFilled } from '../icons/FilledIcons'; -import { supabase, triggerStrategyAdjustment } from '../../lib/supabase'; +import { triggerStrategyAdjustment } from '../../lib/supabase'; +import { db } from '../../lib/db'; interface StrategyAdjustmentSectionProps { clinicId: string | null; @@ -68,13 +69,7 @@ export default function StrategyAdjustmentSection({ clinicId, planId }: Strategy setIsLoading(true); try { // Load latest performance metrics - const { data: perf } = await supabase - .from('performance_metrics') - .select('kpi_progress, strategy_suggestions') - .eq('clinic_id', clinicId!) - .order('created_at', { ascending: false }) - .limit(1) - .single(); + const { data: perf } = await db<{ kpi_progress: unknown; strategy_suggestions: unknown }>('perf.latest', { clinicId: clinicId! }); if (perf) { setKpiProgress((perf.kpi_progress as KpiProgress[]) || []); @@ -83,12 +78,7 @@ export default function StrategyAdjustmentSection({ clinicId, planId }: Strategy } // Load adjustment history - const { data: adj } = await supabase - .from('strategy_adjustments') - .select('id, adjustment_type, description, reason, created_at') - .eq('clinic_id', clinicId!) - .order('created_at', { ascending: false }) - .limit(10); + const { data: adj } = await db('adjustments.list', { clinicId: clinicId! }); if (adj) setHistory(adj); } catch { diff --git a/src/hooks/useMarketingPlan.ts b/src/hooks/useMarketingPlan.ts index e354360..6e3086a 100644 --- a/src/hooks/useMarketingPlan.ts +++ b/src/hooks/useMarketingPlan.ts @@ -1,7 +1,8 @@ import { useState, useEffect } from 'react'; import { useLocation } from 'react-router'; import type { MarketingPlan, ChannelStrategyCard, CalendarData, ContentStrategyData } from '../types/plan'; -import { fetchReportById, fetchActiveContentPlan, supabase } from '../lib/supabase'; +import { fetchReportById, fetchActiveContentPlan } from '../lib/supabase'; +import { db } from '../lib/db'; import { transformReportToPlan } from '../lib/transformPlan'; import { mockPlan } from '../data/mockPlan'; import { mockPlanBanobagi } from '../data/mockPlan_banobagi'; @@ -125,21 +126,13 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult if (!clinicId) { // Try to find clinicId from analysis_runs by run/report ID - const { data: run } = await supabase - .from('analysis_runs') - .select('clinic_id') - .eq('id', id) - .single(); + const { data: run } = await db<{ clinic_id: string }>('run.clinicId', { id }); if (run) clinicId = run.clinic_id; } if (clinicId) { // Fetch clinic info for plan metadata - const { data: clinic } = await supabase - .from('clinics') - .select('name, name_en, url') - .eq('id', clinicId) - .single(); + const { data: clinic } = await db<{ name: string | null; name_en: string | null; url: string | null }>('clinic.byId', { id: clinicId }); if (clinic) { clinicName = clinic.name || ''; clinicNameEn = clinic.name_en || ''; diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..8afeb09 --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,31 @@ +/** + * 데이터베이스 조회·쓰기. 브라우저는 테이블에 직접 접근하지 않고 서버 함수(api/db.js)의 정해진 작업만 부른다. + * 공개 키로 테이블을 읽고 쓰던 구조를 없애기 위한 것이다 (2026-10-07). 반환 형태는 Supabase 클라이언트와 같은 { data, error }. + */ + +export type DbOp = + | 'report.byId' | 'report.status' + | 'run.clinicId' | 'clinic.byId' | 'plan.active' | 'plan.updateEntry' + | 'perf.latest' | 'adjustments.list' + | 'builds.latest' | 'builds.queue' | 'inputs.list' | 'inputs.add'; + +export interface DbResult { + data: T | null; + error: { message: string; code?: string | null } | null; +} + +export async function db(op: DbOp, params: Record = {}): Promise> { + try { + const res = await fetch('/api/db', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ op, params }), + }); + const body = (await res.json().catch(() => null)) as DbResult | null; + if (!body) return { data: null, error: { message: `서버 응답 오류 (${res.status})` } }; + return body; + } catch (e) { + console.error(`[db] ${op} 연결 실패`, e); + return { data: null, error: { message: '서버에 연결할 수 없습니다' } }; + } +} diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index edeb2cf..95b44a0 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -1,9 +1,10 @@ -import { createClient } from "@supabase/supabase-js"; +import { db } from "./db"; const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; -export const supabase = createClient(supabaseUrl, supabaseAnonKey); +// 테이블 조회·쓰기는 서버 함수(api/db.js)를 거친다 (src/lib/db.ts). 브라우저에는 테이블 권한이 없다. +// 아래 주소와 공개 키는 Edge Function 호출(fnHeaders)에만 쓴다. /** Common headers for Edge Function calls (includes JWT auth) */ const fnHeaders = () => ({ @@ -29,11 +30,8 @@ export async function generateMarketingReport(url: string, clinicName?: string) } export async function fetchReportById(reportId: string) { - const { data, error } = await supabase - .from("marketing_reports") - .select("*") - .eq("id", reportId) - .single(); + // 예전 Supabase 클라이언트처럼 행 형태를 호출하는 쪽이 정한다. + const { data, error } = await db("report.byId", { id: reportId }); if (error) throw new Error(`Failed to fetch report: ${error.message}`); return data; @@ -54,11 +52,10 @@ export interface PipelineStatus { } export async function fetchPipelineStatus(reportId: string): Promise { - const { data, error } = await supabase - .from("marketing_reports") - .select("id, status, clinic_name, channel_data, report") - .eq("id", reportId) - .single(); + const { data, error } = await db<{ + id: string; status: string | null; clinic_name?: string; + channel_data: Record | null; report: Record | null; + }>("report.status", { id: reportId }); if (error || !data) throw new Error(`Report not found: ${error?.message}`); @@ -240,14 +237,7 @@ export async function generateContentPlan(reportId: string, clinicId?: string, r * Returns the most recent is_active=true plan with all JSONB columns. */ export async function fetchActiveContentPlan(clinicId: string) { - const { data, error } = await supabase - .from("content_plans") - .select("*") - .eq("clinic_id", clinicId) - .eq("is_active", true) - .order("created_at", { ascending: false }) - .limit(1) - .single(); + const { data, error } = await db("plan.active", { clinicId }); if (error) return null; return data; @@ -262,31 +252,8 @@ export async function updateCalendarEntry( entryId: string, updates: Record, ) { - // Read current calendar - const { data: plan, error: readErr } = await supabase - .from("content_plans") - .select("calendar") - .eq("id", planId) - .single(); - - if (readErr || !plan) throw new Error(`Plan not found: ${readErr?.message}`); - - const calendar = plan.calendar as { weeks: { entries: Record[] }[] }; - - // Find and update entry - for (const week of calendar.weeks) { - for (let i = 0; i < week.entries.length; i++) { - if (week.entries[i].id === entryId) { - week.entries[i] = { ...week.entries[i], ...updates, isManualEdit: true }; - break; - } - } - } - - const { error: writeErr } = await supabase - .from("content_plans") - .update({ calendar }) - .eq("id", planId); + // 읽기·수정·저장을 서버 함수가 한 번에 한다. + const { error: writeErr } = await db("plan.updateEntry", { planId, entryId, updates }); if (writeErr) throw new Error(`Update failed: ${writeErr.message}`); } diff --git a/src/pages/SiteBuildPage.tsx b/src/pages/SiteBuildPage.tsx index 1be46a8..83297ad 100644 --- a/src/pages/SiteBuildPage.tsx +++ b/src/pages/SiteBuildPage.tsx @@ -8,7 +8,7 @@ import { useEffect, useRef, useState } from 'react'; import { useParams, Link } from 'react-router'; import { motion } from 'motion/react'; -import { supabase } from '../lib/supabase'; +import { db } from '../lib/db'; import { BUILD_STEPS, STATE_LABEL, progressOf, stepStates, warningsOf } from '../lib/buildPhases'; import type { BuildState, PhaseRow } from '../lib/buildPhases'; import { CheckFilled, WarningFilled, EyeFilled, PrismFilled } from '../components/icons/FilledIcons'; @@ -40,12 +40,7 @@ export default function SiteBuildPage() { let alive = true; async function tick() { - const { data, error } = await supabase - .from('supporter_builds') - .select('id,clinic_id,clinic_name,status,phase,phases,posts,preview_url,error,updated_at') - .eq('clinic_id', id) - .order('created_at', { ascending: false }) - .limit(1); + const { data, error } = await db('builds.latest', { clinicId: id, fields: 'site' }); if (!alive) return; if (error) setLoadError(error.message); const row = (data?.[0] as Build) ?? null; From 0d1fdd3430a88b13777f3a0a703fa0d9139dbf07 Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Wed, 7 Oct 2026 16:12:34 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20=EC=9D=B5=EB=AA=85=20=ED=82=A4=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EC=A0=95=EB=A6=AC=20SQL=20=EC=9D=84=20?= =?UTF-8?q?=EC=93=B0=EA=B8=B0=20=EC=A0=95=EC=B1=85=EA=B9=8C=EC=A7=80=20?= =?UTF-8?q?=ED=8F=AC=ED=95=A8=ED=95=9C=20=EC=8B=A4=ED=96=89=EB=B3=B8?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B0=94=EA=BE=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/db.js | 2 +- docs/WEBFORAI_FEATURE_FLAGS.md | 2 +- ...T.sql => 2026-10-07_rls_anon_lockdown.sql} | 27 +++++++++++-------- 3 files changed, 18 insertions(+), 13 deletions(-) rename docs/security/{2026-10-07_rls_anon_read_DRAFT.sql => 2026-10-07_rls_anon_lockdown.sql} (57%) diff --git a/api/db.js b/api/db.js index 557428a..964dfd7 100644 --- a/api/db.js +++ b/api/db.js @@ -4,7 +4,7 @@ * 왜 두는가 * 브라우저가 공개 키(anon)로 테이블을 직접 읽고 쓰던 구조라, 공개 키를 가진 누구나 병원 리포트를 내려받고 * 빌드 큐·병원 입력값을 쓸 수 있었다 (2026-10-07 확인). 조회·쓰기를 이 함수로 옮기고 익명 규칙(RLS)을 지운다. - * docs/security/2026-10-07_rls_anon_read_DRAFT.sql + * docs/security/2026-10-07_rls_anon_lockdown.sql * * 원칙 * - 서비스 키(SUPABASE_SERVICE_ROLE_KEY)는 서버 환경변수에만 둔다. diff --git a/docs/WEBFORAI_FEATURE_FLAGS.md b/docs/WEBFORAI_FEATURE_FLAGS.md index ff8f95f..6fee800 100644 --- a/docs/WEBFORAI_FEATURE_FLAGS.md +++ b/docs/WEBFORAI_FEATURE_FLAGS.md @@ -40,6 +40,6 @@ infinith-demo 배포(VITE_SITE 없음)는 모든 화면이 그대로 열린다. 공개 배포에서 열려면 아래가 먼저 끝나야 한다. 스위치를 만들지 않은 이유다. 1. 로그인(Supabase Auth)과 병원별 접근 권한 -2. 데이터베이스 익명 읽기·쓰기 정리: 2026-10-07 실행 (`docs/security/2026-10-07_rls_anon_read_DRAFT.sql`). 데모 화면은 서버 함수 `api/db.js` 로 조회한다 +2. 데이터베이스 익명 읽기·쓰기 정리: 2026-10-07 실행 (`docs/security/2026-10-07_rls_anon_lockdown.sql`). 데모 화면은 서버 함수 `api/db.js` 로 조회한다 3. `src/lib/supabase.ts` 가 공개 번들에 들어가게 되므로 번들 검사 `secrets` 의 Supabase 항목을 어떻게 다룰지 결정 4. 예전 진단 신청 모달(신청마다 자동 빌드 큐 `supporter_builds` 에 넣던 것)은 2026-10-07 삭제했다. 빌드 큐 쓰기는 이제 서버 함수(`api/db.js` 의 `builds.queue`)만 할 수 있다. 공개 폼에서 빌드를 받으려면 사람 승인 단계를 먼저 둔다. diff --git a/docs/security/2026-10-07_rls_anon_read_DRAFT.sql b/docs/security/2026-10-07_rls_anon_lockdown.sql similarity index 57% rename from docs/security/2026-10-07_rls_anon_read_DRAFT.sql rename to docs/security/2026-10-07_rls_anon_lockdown.sql index bd36b8a..8bd552e 100644 --- a/docs/security/2026-10-07_rls_anon_read_DRAFT.sql +++ b/docs/security/2026-10-07_rls_anon_lockdown.sql @@ -1,18 +1,16 @@ -- ════════════════════════════════════════════════════════════════════════ --- 초안 (실행하지 않음) · 2026-10-07 --- 익명(anon) 키로 실제 병원 데이터를 읽을 수 있는 RLS 정책 정리 +-- 익명(anon) 키로 테이블을 읽고 쓰던 RLS 정책 정리 · 2026-10-07 -- -- 확인한 사실 (2026-10-07, anon 키로 행 수만 조회): --- marketing_reports 103 · clinics 12 · channel_snapshots 189 · analysis_runs 53 --- supporter_builds 3 · supporter_inputs 2 행이 anon 키로 읽힌다. --- anon 키는 infinith-demo 번들에 공개로 들어 있다 (webforai.kr 번들에서는 2026-10-07 c2e7d5a 로 제거). +-- marketing_reports 103 · clinics 12 · analysis_runs 53 · channel_snapshots 189 · screenshots 112 +-- content_plans 6 · supporter_builds 3 · supporter_inputs 2 행이 anon 키로 읽혔다. +-- anon 키는 webforai.kr 번들(2026-10-07 c2e7d5a 로 제거)과 infinith-demo 번들에 공개로 들어 있었다. -- --- 실행 전 확인할 것 (haewon 결정 필요): --- 1. infinith-demo 의 리포트·플랜·스튜디오·병원 확인 화면(/report, /plan, /clinic, /supporters, /build)은 --- anon 키로 이 테이블을 읽는다. 아래를 실행하면 그 화면들이 빈 화면이 된다. --- 대안: 로그인(Supabase Auth) 도입 후 authenticated 로 바꾸거나, 읽기를 Edge Function(service role) 뒤로 옮긴다. --- 2. 운영 DB 변경이다. 실행 전에 영향 범위를 확인받고, Supabase 대시보드 SQL 편집기에서 실행한다. --- 3. 되돌리기: 각 정책을 원래 마이그레이션의 CREATE POLICY 문으로 다시 만들면 된다 (파일명은 아래 주석). +-- 선행 조건 (2026-10-07 완료): +-- infinith-demo 화면의 조회·쓰기를 서버 함수 api/db.js(service role)로 옮겼다. +-- 워커(workers/supporters-build), Edge Function, scripts/* 는 service role 키를 쓴다. 이 SQL 의 영향을 받지 않는다. +-- +-- 되돌리기: 각 정책을 아래 주석의 마이그레이션 파일에 있는 CREATE POLICY 문으로 다시 만든다. -- ════════════════════════════════════════════════════════════════════════ begin; @@ -36,6 +34,13 @@ drop policy if exists "public_read_registry" on public.clinic_registry; -- 20260907_supporter_builds.sql drop policy if exists "anon can read builds" on public.supporter_builds; drop policy if exists "anon can read inputs" on public.supporter_inputs; +drop policy if exists "anon can insert inputs" on public.supporter_inputs; + +-- 20260907_supporter_builds_queue.sql (익명 빌드 큐 등록: 등록되면 워커가 빌드·배포를 시작한다) +drop policy if exists "anon can queue builds" on public.supporter_builds; + +-- 20260831_discovery_leads.sql (예전 진단 신청 폼. 지금은 쓰는 코드가 없다) +drop policy if exists "anon can insert discovery leads" on public.discovery_leads; -- 스토리지 버킷의 공개 읽기(20260407_screenshots_storage.sql "public_read_screenshots")는 -- 병원 채널 스크린샷 이미지다. 버킷 공개 여부와 함께 별도로 결정한다.