Files

277 lines
9.8 KiB
JavaScript
Raw Permalink Normal View History

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' },
});
}
// 管理 app3510HTTP):root 是管理頁
const adminApp = express();
adminApp.get('/', sendPage('admin.html'));
adminApp.get('/admin', (req, res) => res.redirect('/'));
adminApp.use(shared);
adminApp.use(errorHandler);
// 掃描 app3511HTTPS):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} (手機請走這個)`);
});