const express = require('express'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const http = require('http'); const https = require('https'); const multer = require('multer'); const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); const PHOTO_DIR = path.join(DATA_DIR, 'photos'); const VIDEO_DIR = path.join(DATA_DIR, 'videos'); const SELF_CERT_DIR = path.join(DATA_DIR, 'certs'); // 自簽憑證存放處(找不到外部憑證時的備援) const EXTERNAL_CERT_DIR = process.env.CERT_DIR || ''; // 外掛正式憑證目錄(例如掛載 /certs) const TMP_DIR = path.join(DATA_DIR, 'tmp'); for (const d of [DATA_DIR, PHOTO_DIR, VIDEO_DIR, SELF_CERT_DIR, TMP_DIR]) { fs.mkdirSync(d, { recursive: true }); } const DB_FILE = path.join(DATA_DIR, 'pairs.json'); const TARGETS_META_FILE = path.join(DATA_DIR, 'targets.json'); const TARGETS_MIND_FILE = path.join(DATA_DIR, 'targets.mind'); const ADMIN_PORT = Number(process.env.ADMIN_PORT) || 3510; // 管理頁(HTTP) const VIEWER_PORT = Number(process.env.VIEWER_PORT) || 3511; // 掃描頁(HTTPS,相機需要安全連線) const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png']); const VIDEO_EXTS = new Set(['.mp4', '.m4v', '.mov', '.webm']); function timestampName(d = new Date()) { const p = (n) => String(n).padStart(2, '0'); return ( `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}` + `_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}` ); } function loadJson(file, fallback) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; } } function saveJson(file, obj) { fs.writeFileSync(file, JSON.stringify(obj, null, 2)); } let db = loadJson(DB_FILE, { pairs: [], updatedAt: 0 }); function persistDb() { db.updatedAt = Date.now(); saveJson(DB_FILE, db); } const upload = multer({ dest: TMP_DIR, limits: { fileSize: 2 * 1024 * 1024 * 1024 }, }); // 共用路由(API 與資料檔),掛在管理與掃描兩個 app 上 const shared = express.Router(); shared.use('/vendor', express.static(path.join(__dirname, 'public', 'vendor'))); shared.use('/data/photos', express.static(PHOTO_DIR)); shared.use('/data/videos', express.static(VIDEO_DIR)); shared.get('/data/targets.mind', (req, res) => { if (!fs.existsSync(TARGETS_MIND_FILE)) return res.status(404).json({ error: 'no targets' }); res.sendFile(TARGETS_MIND_FILE); }); function getState() { const targets = loadJson(TARGETS_META_FILE, null); const needsCompile = db.pairs.length > 0 ? !targets || (targets.compiledAt || 0) < db.updatedAt : !!(targets && targets.targets.length > 0); return { pairs: db.pairs, targets, needsCompile, ports: { admin: ADMIN_PORT, viewer: VIEWER_PORT }, }; } shared.get('/api/state', (req, res) => { res.json(getState()); }); function cleanupTmp(req) { const files = []; if (req.file) files.push(req.file); if (req.files) for (const list of Object.values(req.files)) files.push(...list); for (const f of files) fs.rm(f.path, { force: true }, () => {}); } shared.post( '/api/pairs', upload.fields([ { name: 'photo', maxCount: 1 }, { name: 'video', maxCount: 1 }, ]), (req, res) => { const photo = req.files && req.files.photo && req.files.photo[0]; const video = req.files && req.files.video && req.files.video[0]; if (!photo || !video) { cleanupTmp(req); return res.status(400).json({ error: '需要同時上傳照片與影片' }); } const photoExt = path.extname(photo.originalname).toLowerCase(); const videoExt = path.extname(video.originalname).toLowerCase(); if (!PHOTO_EXTS.has(photoExt)) { cleanupTmp(req); return res.status(400).json({ error: `照片格式僅支援 ${[...PHOTO_EXTS].join(' / ')}` }); } if (!VIDEO_EXTS.has(videoExt)) { cleanupTmp(req); return res.status(400).json({ error: `影片格式僅支援 ${[...VIDEO_EXTS].join(' / ')}` }); } const id = Date.now().toString(36) + crypto.randomBytes(3).toString('hex'); const photoFile = id + photoExt; const videoFile = id + videoExt; fs.renameSync(photo.path, path.join(PHOTO_DIR, photoFile)); fs.renameSync(video.path, path.join(VIDEO_DIR, videoFile)); const pair = { id, name: timestampName(), photo: photoFile, video: videoFile, createdAt: Date.now(), }; db.pairs.push(pair); persistDb(); res.json({ ok: true, pair }); } ); // 裁切框(相對座標 0~1,null 表示用整張照片)。只影響編譯輸入,照片檔不動 function isValidCrop(c) { if (c === null) return true; if (!c || typeof c !== 'object') return false; const nums = [c.x, c.y, c.w, c.h]; if (!nums.every((n) => typeof n === 'number' && Number.isFinite(n))) return false; return c.x >= 0 && c.y >= 0 && c.w > 0 && c.h > 0 && c.x + c.w <= 1.0001 && c.y + c.h <= 1.0001; } shared.put('/api/pairs/:id/crop', express.json(), (req, res) => { const pair = db.pairs.find((p) => p.id === req.params.id); if (!pair) return res.status(404).json({ error: '找不到這組配對' }); const crop = req.body ? req.body.crop : undefined; if (crop === undefined || !isValidCrop(crop)) { return res.status(400).json({ error: 'crop 格式錯誤(需為 null 或 {x,y,w,h} 相對座標)' }); } if (crop === null) delete pair.crop; else pair.crop = { x: +crop.x.toFixed(4), y: +crop.y.toFixed(4), w: +crop.w.toFixed(4), h: +crop.h.toFixed(4) }; persistDb(); res.json({ ok: true, pair }); }); shared.delete('/api/pairs/:id', (req, res) => { const idx = db.pairs.findIndex((p) => p.id === req.params.id); if (idx === -1) return res.status(404).json({ error: '找不到這組配對' }); const [pair] = db.pairs.splice(idx, 1); fs.rm(path.join(PHOTO_DIR, pair.photo), { force: true }, () => {}); fs.rm(path.join(VIDEO_DIR, pair.video), { force: true }, () => {}); persistDb(); res.json({ ok: true }); }); // 管理頁在瀏覽器端把所有照片編譯成 .mind 特徵檔後上傳到這裡 shared.post('/api/targets', upload.single('mind'), (req, res) => { let mapping; try { mapping = JSON.parse(req.body.mapping); if (!Array.isArray(mapping.targets)) throw new Error('bad mapping'); } catch { cleanupTmp(req); return res.status(400).json({ error: 'mapping 格式錯誤' }); } if (!req.file) return res.status(400).json({ error: '缺少 .mind 檔' }); const known = new Set(db.pairs.map((p) => p.id)); for (const t of mapping.targets) { if (!known.has(t.pairId)) { cleanupTmp(req); return res.status(400).json({ error: `pairId ${t.pairId} 不存在,請重新整理後再編譯` }); } } fs.renameSync(req.file.path, TARGETS_MIND_FILE); saveJson(TARGETS_META_FILE, { compiledAt: Date.now(), targets: mapping.targets }); res.json({ ok: true, state: getState() }); }); function errorHandler(err, req, res, next) { console.error(err); res.status(500).json({ error: err.message || 'server error' }); } // HTML 一律不快取,避免手機拿到舊版頁面 function sendPage(file) { return (req, res) => res.sendFile(path.join(__dirname, 'public', file), { headers: { 'Cache-Control': 'no-store' }, }); } // 管理 app(3510,HTTP):root 是管理頁 const adminApp = express(); adminApp.get('/', sendPage('admin.html')); adminApp.get('/admin', (req, res) => res.redirect('/')); adminApp.use(shared); adminApp.use(errorHandler); // 掃描 app(3511,HTTPS):root 是掃描頁 const viewerApp = express(); viewerApp.get('/', sendPage('index.html')); viewerApp.use(shared); viewerApp.use(errorHandler); function ensureCerts() { // 1. 明確指定的憑證檔(環境變數 SSL_KEY / SSL_CERT 為完整路徑) if (process.env.SSL_KEY && process.env.SSL_CERT) { console.log('使用指定憑證:', process.env.SSL_CERT); return { key: fs.readFileSync(process.env.SSL_KEY), cert: fs.readFileSync(process.env.SSL_CERT) }; } // 2. 外掛憑證目錄(CERT_DIR),依常見檔名組合搜尋 if (EXTERNAL_CERT_DIR) { const candidates = [ ['privkey.pem', 'fullchain.pem'], // Let's Encrypt / Synology 匯出 ['privkey.pem', 'cert.pem'], ['server.key', 'server.crt'], ['key.pem', 'cert.pem'], ['ssl.key', 'ssl.crt'], ]; for (const [k, c] of candidates) { const keyFile = path.join(EXTERNAL_CERT_DIR, k); const certFile = path.join(EXTERNAL_CERT_DIR, c); if (fs.existsSync(keyFile) && fs.existsSync(certFile)) { console.log(`使用外掛憑證: ${certFile} + ${keyFile}`); return { key: fs.readFileSync(keyFile), cert: fs.readFileSync(certFile) }; } } console.warn(`在 ${EXTERNAL_CERT_DIR} 找不到憑證(試過: ${candidates.map((p) => p.join('+')).join(', ')}),改用自簽憑證`); } // 3. 備援:自動產生自簽憑證 const keyFile = path.join(SELF_CERT_DIR, 'server.key'); const certFile = path.join(SELF_CERT_DIR, 'server.crt'); if (fs.existsSync(keyFile) && fs.existsSync(certFile)) { return { key: fs.readFileSync(keyFile), cert: fs.readFileSync(certFile) }; } const selfsigned = require('selfsigned'); const pems = selfsigned.generate([{ name: 'commonName', value: 'photo-live' }], { days: 3650, keySize: 2048, extensions: [ { name: 'subjectAltName', altNames: [{ type: 2, value: 'localhost' }, { type: 2, value: 'photo-live' }], }, ], }); fs.writeFileSync(keyFile, pems.private); fs.writeFileSync(certFile, pems.cert); console.log('已產生自簽 HTTPS 憑證於', SELF_CERT_DIR); return { key: pems.private, cert: pems.cert }; } http.createServer(adminApp).listen(ADMIN_PORT, () => { console.log(`管理頁 (HTTP): http://0.0.0.0:${ADMIN_PORT}`); }); https.createServer(ensureCerts(), viewerApp).listen(VIEWER_PORT, () => { console.log(`掃描頁 (HTTPS): https://0.0.0.0:${VIEWER_PORT} (手機請走這個)`); });