From 5f16515dab2fe635aad0e9737062eca887eff7dd Mon Sep 17 00:00:00 2001 From: EENE Dashboard Date: Sat, 6 Jun 2026 01:53:01 +0900 Subject: [PATCH] fix: allow cross-origin team photos on Render for Vercel frontend Helmet CORP blocked /uploads images from eene-dashboard.vercel.app. Also add photo file upload to db:push-remote and db:push-photos script. Co-authored-by: Cursor --- backend/package.json | 3 +- backend/scripts/sync-to-remote.ts | 64 +++++++++++++++++++++++++++++-- backend/src/app.ts | 7 +++- 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/backend/package.json b/backend/package.json index 534414d..9f05cdb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,7 +14,8 @@ "db:seed": "tsx prisma/seed.ts", "db:import-hr": "tsx scripts/import-hr-data.ts", "db:sync-remote": "tsx scripts/sync-from-remote.ts", - "db:push-remote": "tsx scripts/sync-to-remote.ts" + "db:push-remote": "tsx scripts/sync-to-remote.ts", + "db:push-photos": "tsx scripts/sync-to-remote.ts --photos-only" }, "dependencies": { "@prisma/client": "^6.0.0", diff --git a/backend/scripts/sync-to-remote.ts b/backend/scripts/sync-to-remote.ts index acd0ca4..209e080 100644 --- a/backend/scripts/sync-to-remote.ts +++ b/backend/scripts/sync-to-remote.ts @@ -4,11 +4,46 @@ * 환경변수 TARGET_API_URL 로 대상 변경 가능 */ import 'dotenv/config'; +import fs from 'fs'; +import path from 'path'; import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); const TARGET = (process.env.TARGET_API_URL || 'https://eene-dashboard-backend.onrender.com').replace(/\/$/, ''); const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리']; +const PHOTOS_ONLY = process.argv.includes('--photos-only'); +const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || path.join(__dirname, '../../uploads')); +const TEAM_DIR = path.join(UPLOAD_DIR, 'team'); + +function resolveLocalPhotoPath(photoUrl: string | null | undefined): string | null { + if (!photoUrl || /^https?:\/\//i.test(photoUrl) || photoUrl.startsWith('data:')) return null; + const filename = photoUrl.replace(/^\/uploads\/team\//, '').replace(/^uploads\/team\//, ''); + if (!filename || filename.includes('..')) return null; + const filePath = path.join(TEAM_DIR, filename); + return fs.existsSync(filePath) ? filePath : null; +} + +function mimeForExt(ext: string): string { + if (ext === '.png') return 'image/png'; + if (ext === '.webp') return 'image/webp'; + if (ext === '.gif') return 'image/gif'; + return 'image/jpeg'; +} + +async function uploadPhotoToRemote(filePath: string): Promise { + const buf = fs.readFileSync(filePath); + const ext = path.extname(filePath).toLowerCase(); + const form = new FormData(); + form.append('photo', new Blob([buf], { type: mimeForExt(ext) }), path.basename(filePath)); + + const res = await fetch(`${TARGET}/api/team-members/photo`, { method: 'POST', body: form }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`photo upload failed: ${res.status} ${text}`); + } + const data = (await res.json()) as { url: string }; + return data.url; +} async function api(method: string, path: string, body?: unknown): Promise { const res = await fetch(`${TARGET}${path}`, { @@ -67,13 +102,27 @@ async function syncTeamMembers(): Promise> { for (const local of locals) { const key = memberKey(local.name, local.cell); + let photoUrl = local.photoUrl; + + const localPhotoPath = resolveLocalPhotoPath(local.photoUrl); + if (localPhotoPath) { + try { + photoUrl = await uploadPhotoToRemote(localPhotoPath); + console.log(` 📷 ${local.name} — ${path.basename(localPhotoPath)}`); + } catch (err) { + console.warn(` ⚠ ${local.name} photo skip:`, (err as Error).message); + } + } else if (local.photoUrl) { + console.warn(` ⚠ ${local.name} — local file not found: ${local.photoUrl}`); + } + const payload = { name: local.name, rank: local.rank, role: local.role, cell: local.cell, contact: local.contact, - photoUrl: local.photoUrl, + photoUrl, sortOrder: local.sortOrder, isActive: true, }; @@ -216,18 +265,25 @@ async function main() { const localMembers = await prisma.teamMember.count({ where: { isActive: true } }); console.log(` Local: ${localTasks} tasks, ${localMembers} team members`); - if (localTasks === 0) { + if (!PHOTOS_ONLY && localTasks === 0) { throw new Error('로컬 DB에 업무가 없습니다. 먼저 데이터가져오기.bat 또는 작업을 진행하세요.'); } - console.log('👥 Uploading team members...'); + console.log(`👥 Uploading team members${PHOTOS_ONLY ? ' + photos' : ''}...`); + console.log(` Local photos dir: ${TEAM_DIR}`); const memberIdMap = await syncTeamMembers(); + if (PHOTOS_ONLY) { + console.log('\n✅ Photos sync complete!'); + console.log(' Site: https://eene-dashboard.vercel.app/'); + return; + } + console.log('🗑️ Clearing remote tasks...'); await clearRemoteTasks(); console.log('📋 Uploading tasks...'); - const count = await syncTasks(memberIdMap); + await syncTasks(memberIdMap); console.log('📐 Uploading column order...'); await syncColumnConfigs(); diff --git a/backend/src/app.ts b/backend/src/app.ts index 2256f97..46eba47 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -8,7 +8,12 @@ import routes from './routes'; const app = express(); -app.use(helmet()); +// Vercel 프론트에서 Render /uploads 이미지를 img로 불러올 수 있도록 cross-origin 허용 +app.use( + helmet({ + crossOriginResourcePolicy: { policy: 'cross-origin' }, + }), +); const allowedOrigins = [ 'http://localhost:3000', 'http://localhost:5173',