const express = require('express'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const cors = require('cors'); const crypto = require('crypto'); const { v4: uuidv4 } = require('uuid'); const app = express(); const PORT = process.env.PORT || 3000; const FIRMWARE_DIR = path.join(__dirname, 'uploads'); const META_FILE = path.join(FIRMWARE_DIR, '_metadata.json'); const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || '*'; if (!fs.existsSync(FIRMWARE_DIR)) { fs.mkdirSync(FIRMWARE_DIR, { recursive: true }); } // ── Middleware ────────────────────────────────────────────────────────────── app.use(cors({ origin: ALLOWED_ORIGIN })); app.use(express.json()); // 펌웨어 바이너리 정적 서빙 (nginx 없이 직접 접근 시 사용) app.use('/firmware/files', express.static(FIRMWARE_DIR)); // ── Firmware metadata (파일 기반, MVP용) ──────────────────────────────────── function loadMeta() { if (!fs.existsSync(META_FILE)) return []; try { return JSON.parse(fs.readFileSync(META_FILE, 'utf8')); } catch { return []; } } function saveMeta(list) { fs.writeFileSync(META_FILE, JSON.stringify(list, null, 2)); } // ── Multer 설정 ───────────────────────────────────────────────────────────── const storage = multer.diskStorage({ destination: FIRMWARE_DIR, filename: (_req, file, cb) => { const safe = Date.now() + '-' + crypto.randomBytes(4).toString('hex'); cb(null, safe + path.extname(file.originalname)); }, }); const upload = multer({ storage, fileFilter: (_req, file, cb) => { if (path.extname(file.originalname).toLowerCase() === '.bin') { cb(null, true); } else { cb(new Error('.bin 파일만 업로드 가능합니다')); } }, limits: { fileSize: 32 * 1024 * 1024 }, // 32 MB }); // ── 라우트 ────────────────────────────────────────────────────────────────── // 헬스 체크 app.get('/api/health', (_req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // 펌웨어 목록 app.get('/api/firmware', (_req, res) => { res.json(loadMeta()); }); // 펌웨어 업로드 // 지원 필드: firmware(필수), bootloader(선택), partitions(선택), boot_app0(선택) app.post( '/api/firmware/upload', upload.fields([ { name: 'firmware', maxCount: 1 }, { name: 'bootloader', maxCount: 1 }, { name: 'partitions', maxCount: 1 }, { name: 'boot_app0', maxCount: 1 }, ]), (req, res) => { try { const files = req.files || {}; if (!files.firmware) { return res.status(400).json({ error: '펌웨어 파일(firmware)이 필요합니다' }); } const { name, version, description, chipFamily, flashAddress } = req.body; const list = loadMeta(); const id = uuidv4(); const parts = []; if (files.bootloader) { parts.push({ file: files.bootloader[0].filename, offset: '0x0000', label: 'Bootloader', }); } if (files.partitions) { parts.push({ file: files.partitions[0].filename, offset: '0x8000', label: 'Partition Table', }); } if (files.boot_app0) { parts.push({ file: files.boot_app0[0].filename, offset: '0xe000', label: 'Boot App0', }); } // 부트로더가 따로 없으면(병합 바이너리) 0x0, 있으면 0x10000 const appOffset = flashAddress || (files.bootloader ? '0x10000' : '0x0000'); parts.push({ file: files.firmware[0].filename, offset: appOffset, label: 'Application', size: files.firmware[0].size, }); const entry = { id, name: name || '이름 없음', version: version || '1.0.0', description: description || '', chipFamily: chipFamily || 'ESP32-S3', createdAt: new Date().toISOString(), parts, }; list.unshift(entry); saveMeta(list); res.json({ success: true, id, firmware: entry }); } catch (err) { res.status(500).json({ error: err.message }); } } ); // esp-web-tools manifest.json 생성 app.get('/api/firmware/:id/manifest', (req, res) => { const firmware = loadMeta().find(f => f.id === req.params.id); if (!firmware) return res.status(404).json({ error: '펌웨어를 찾을 수 없습니다' }); // esp-web-tools manifest 스펙 const manifest = { name: firmware.name, version: firmware.version, new_install_prompt_erase: true, new_install_improv_wait_ms: 0, // Improv WiFi 단계 건너뜀 → 완료 후 Close 버튼만 표시 builds: [{ chipFamily: firmware.chipFamily, parts: firmware.parts.map(p => ({ path: `/firmware/files/${p.file}`, offset: parseInt(p.offset, 16), })), }], }; // CORS 허용 헤더 (esp-web-tools가 fetch할 때 필요) res.setHeader('Access-Control-Allow-Origin', '*'); res.json(manifest); }); // 단일 펌웨어 삭제 app.delete('/api/firmware/:id', (req, res) => { const list = loadMeta(); const index = list.findIndex(f => f.id === req.params.id); if (index === -1) return res.status(404).json({ error: '펌웨어를 찾을 수 없습니다' }); list[index].parts.forEach(p => { const fp = path.join(FIRMWARE_DIR, p.file); if (fs.existsSync(fp)) fs.unlinkSync(fp); }); list.splice(index, 1); saveMeta(list); res.json({ success: true }); }); // ── 오류 핸들러 ───────────────────────────────────────────────────────────── app.use((err, _req, res, _next) => { console.error(err.message); res.status(400).json({ error: err.message }); }); app.listen(PORT, () => { console.log(`ESP32 Flasher Backend → http://localhost:${PORT}`); });