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 <cursoragent@cursor.com>
This commit is contained in:
@@ -14,7 +14,8 @@
|
|||||||
"db:seed": "tsx prisma/seed.ts",
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
"db:import-hr": "tsx scripts/import-hr-data.ts",
|
"db:import-hr": "tsx scripts/import-hr-data.ts",
|
||||||
"db:sync-remote": "tsx scripts/sync-from-remote.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": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.0.0",
|
"@prisma/client": "^6.0.0",
|
||||||
|
|||||||
@@ -4,11 +4,46 @@
|
|||||||
* 환경변수 TARGET_API_URL 로 대상 변경 가능
|
* 환경변수 TARGET_API_URL 로 대상 변경 가능
|
||||||
*/
|
*/
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
const TARGET = (process.env.TARGET_API_URL || 'https://eene-dashboard-backend.onrender.com').replace(/\/$/, '');
|
const TARGET = (process.env.TARGET_API_URL || 'https://eene-dashboard-backend.onrender.com').replace(/\/$/, '');
|
||||||
const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리'];
|
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<string> {
|
||||||
|
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<T>(method: string, path: string, body?: unknown): Promise<T> {
|
async function api<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
const res = await fetch(`${TARGET}${path}`, {
|
const res = await fetch(`${TARGET}${path}`, {
|
||||||
@@ -67,13 +102,27 @@ async function syncTeamMembers(): Promise<Map<string, string>> {
|
|||||||
|
|
||||||
for (const local of locals) {
|
for (const local of locals) {
|
||||||
const key = memberKey(local.name, local.cell);
|
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 = {
|
const payload = {
|
||||||
name: local.name,
|
name: local.name,
|
||||||
rank: local.rank,
|
rank: local.rank,
|
||||||
role: local.role,
|
role: local.role,
|
||||||
cell: local.cell,
|
cell: local.cell,
|
||||||
contact: local.contact,
|
contact: local.contact,
|
||||||
photoUrl: local.photoUrl,
|
photoUrl,
|
||||||
sortOrder: local.sortOrder,
|
sortOrder: local.sortOrder,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
};
|
};
|
||||||
@@ -216,18 +265,25 @@ async function main() {
|
|||||||
const localMembers = await prisma.teamMember.count({ where: { isActive: true } });
|
const localMembers = await prisma.teamMember.count({ where: { isActive: true } });
|
||||||
console.log(` Local: ${localTasks} tasks, ${localMembers} team members`);
|
console.log(` Local: ${localTasks} tasks, ${localMembers} team members`);
|
||||||
|
|
||||||
if (localTasks === 0) {
|
if (!PHOTOS_ONLY && localTasks === 0) {
|
||||||
throw new Error('로컬 DB에 업무가 없습니다. 먼저 데이터가져오기.bat 또는 작업을 진행하세요.');
|
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();
|
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...');
|
console.log('🗑️ Clearing remote tasks...');
|
||||||
await clearRemoteTasks();
|
await clearRemoteTasks();
|
||||||
|
|
||||||
console.log('📋 Uploading tasks...');
|
console.log('📋 Uploading tasks...');
|
||||||
const count = await syncTasks(memberIdMap);
|
await syncTasks(memberIdMap);
|
||||||
|
|
||||||
console.log('📐 Uploading column order...');
|
console.log('📐 Uploading column order...');
|
||||||
await syncColumnConfigs();
|
await syncColumnConfigs();
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ import routes from './routes';
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(helmet());
|
// Vercel 프론트에서 Render /uploads 이미지를 img로 불러올 수 있도록 cross-origin 허용
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
const allowedOrigins = [
|
const allowedOrigins = [
|
||||||
'http://localhost:3000',
|
'http://localhost:3000',
|
||||||
'http://localhost:5173',
|
'http://localhost:5173',
|
||||||
|
|||||||
Reference in New Issue
Block a user