摘要: 拿掉 360-player-web(nginx)服務與 docker/nginx/,改在 server.js 用 node:https 直接提供 HTTPS,部署從兩個容器變成一個。 根本原因: 先前為了 TLS 而多開一個 nginx 容器。分開的主因是「憑證續期時能 reload 而不重啟 app」——重啟會殺掉正在跑的 ffmpeg 轉檔,一部 4K 360 影片動輒數小時。 但 Node 的 https.Server 本來就有 setSecureContext(),可以熱換憑證不重啟行程, 這個理由不成立,多一個容器只是多一層維護成本。 影響: - 需維護額外的 nginx 映像檔與 entrypoint.sh - 影片經反向代理多一跳,且必須小心處理 proxy_buffering 與 SSE 逾時, 設錯會讓 Range 串流被寫進暫存檔、或讓轉檔進度停止更新 修法: - server.js 新增 TLS:讀取 SSL_CERT_DIR 的憑證,以 fs.watch 監看該資料夾, 檔案變動時 debounce 1 秒後呼叫 setSecureContext() 熱套用 - 中介憑證串進 cert 而非 ca:Node 只送出 cert 的內容,ca 是驗證對方用的 信任庫、不會送給瀏覽器,放錯會導致憑證鏈不完整 - 串接前正規化 PEM(去 CRLF、補結尾換行),沿用原 nginx entrypoint 的處理 - config.json 新增 httpsPort(預設 0 = 停用)與 certDir,本機開發不需憑證; 憑證讀不到時退回只提供 HTTP 並印警告,不讓服務起不來 - docker-compose.yml 併回單一服務,憑證改掛 /certs;Dockerfile 補上 HTTPS_PORT、SSL_CERT_DIR,EXPOSE 改為 8443 - 刪除 docker/nginx/ 驗證(實機執行,非僅靜態檢查): - 以 SSL_CERT_DIR=/volume1/docker/certs 啟動,log 顯示 「HTTPS:jianmiau.tk — 14 天後到期」,https 的 /api/config 回 200 - openssl s_client 確認送出完整三層憑證鏈 (jianmiau.tk → Let's Encrypt YR2 → ISRG Root YR) - HTTPS 上的 Range 請求正常:檔頭與中段各取一段皆回 206 且長度正確 - 熱換測試:換上 CN=hotswap-test.local 的自簽憑證後,log 出現 「憑證已重新載入」,s_client 讀到新 CN,且行程 PID 與啟動時間不變 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
324 lines
13 KiB
JavaScript
324 lines
13 KiB
JavaScript
import express from 'express';
|
||
import fs from 'node:fs/promises';
|
||
import fsSync from 'node:fs';
|
||
import path from 'node:path';
|
||
import os from 'node:os';
|
||
import https from 'node:https';
|
||
import { X509Certificate } from 'node:crypto';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { ProbeCache } from './lib/probe.js';
|
||
import { Transcoder, MODE_LABEL } from './lib/transcode.js';
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
// CONFIG=path/to/other.json lets you run a second instance (e.g. for tests) without touching config.json.
|
||
const configPath = path.resolve(__dirname, process.env.CONFIG || 'config.json');
|
||
const config = JSON.parse(await fs.readFile(configPath, 'utf8'));
|
||
// Environment overrides (used by the Docker image: /videos, /cache).
|
||
if (process.env.PORT) config.port = Number(process.env.PORT);
|
||
if (process.env.HOST) config.host = process.env.HOST;
|
||
if (process.env.VIDEO_DIR) config.videoDir = process.env.VIDEO_DIR;
|
||
if (process.env.CACHE_DIR) config.cacheDir = process.env.CACHE_DIR;
|
||
if (process.env.HTTPS_PORT) config.httpsPort = Number(process.env.HTTPS_PORT);
|
||
if (process.env.SSL_CERT_DIR) config.certDir = process.env.SSL_CERT_DIR;
|
||
const VIDEO_DIR = path.resolve(__dirname, config.videoDir);
|
||
const CACHE_DIR = path.resolve(__dirname, config.cacheDir);
|
||
const CERT_DIR = path.resolve(__dirname, config.certDir || './certificate');
|
||
// DSM 匯出的檔名是 RSA-cert.pem 之類的,所以檔名可以個別覆寫。
|
||
const CERT_FILES = {
|
||
cert: process.env.SSL_CERT_FILE_NAME || 'cert.pem',
|
||
chain: process.env.SSL_CHAIN_FILE_NAME || 'chain.pem',
|
||
key: process.env.SSL_KEY_FILE_NAME || 'privkey.pem',
|
||
};
|
||
const EXT = new Set((config.extensions || ['.mp4']).map(e => e.toLowerCase()));
|
||
const QUALITY_IDS = new Set(config.qualities.map(q => q.id));
|
||
|
||
const probeCache = new ProbeCache(path.join(CACHE_DIR, 'probe-cache.json'));
|
||
|
||
const isSafeName = (n) =>
|
||
typeof n === 'string' && n.length > 0 && n !== '.' && n !== '..' &&
|
||
!n.includes('/') && !n.includes('\\') && path.basename(n) === n;
|
||
|
||
async function resolveSource(name) {
|
||
if (!isSafeName(name)) throw Object.assign(new Error('檔名不合法'), { status: 400 });
|
||
const file = path.join(VIDEO_DIR, name);
|
||
let stat;
|
||
try { stat = await fs.stat(file); } catch { throw Object.assign(new Error('找不到影片'), { status: 404 }); }
|
||
const info = await probeCache.get(file, stat);
|
||
return { file, stat, info };
|
||
}
|
||
|
||
const transcoder = new Transcoder({ cacheDir: CACHE_DIR, qualities: config.qualities, resolveSource });
|
||
|
||
/** Run async fn over items with bounded concurrency, preserving order. */
|
||
async function mapLimit(items, limit, fn) {
|
||
const out = new Array(items.length);
|
||
let i = 0;
|
||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||
while (i < items.length) { const idx = i++; out[idx] = await fn(items[idx], idx); }
|
||
}));
|
||
return out;
|
||
}
|
||
|
||
async function listVideos() {
|
||
let entries;
|
||
try { entries = await fs.readdir(VIDEO_DIR, { withFileTypes: true }); }
|
||
catch (e) { throw Object.assign(new Error(`無法讀取資料夾 ${VIDEO_DIR}:${e.message}`), { status: 500 }); }
|
||
const names = entries
|
||
.filter(e => e.isFile() && EXT.has(path.extname(e.name).toLowerCase()))
|
||
.map(e => e.name)
|
||
.sort((a, b) => a.localeCompare(b, 'zh-Hant', { numeric: true }));
|
||
|
||
return mapLimit(names, 4, describeVideo);
|
||
}
|
||
|
||
async function describeVideo(name) {
|
||
const file = path.join(VIDEO_DIR, name);
|
||
const stat = await fs.stat(file);
|
||
let info = null, error = null;
|
||
try { info = await probeCache.get(file, stat); }
|
||
catch (e) { error = e.message; }
|
||
const variants = {};
|
||
if (info?.video) {
|
||
for (const q of config.qualities) {
|
||
if (q.width >= info.video.width) continue; // would not shrink anything
|
||
const height = Math.round((q.width * info.video.height) / info.video.width / 2) * 2;
|
||
variants[q.id] = {
|
||
label: q.label, width: q.width, height, bitrate: q.bitrate,
|
||
...(await transcoder.variantStatus(name, q.id, stat)),
|
||
};
|
||
}
|
||
}
|
||
return { name, size: stat.size, mtime: stat.mtimeMs, info, error, variants };
|
||
}
|
||
|
||
const app = express();
|
||
app.disable('x-powered-by');
|
||
app.use(express.json());
|
||
|
||
// Access log for media requests (set LOG_STREAM=0 to silence).
|
||
if (process.env.LOG_STREAM !== '0') {
|
||
app.use('/stream', (req, res, next) => {
|
||
const t0 = Date.now();
|
||
res.on('close', () => {
|
||
const sent = res.socket ? res.socket.bytesWritten : 0;
|
||
console.log(`[stream] ${res.statusCode} ${decodeURIComponent(req.url)} range=${req.headers.range || '-'} ` +
|
||
`${(res.getHeader('content-length') || '?')}B ${Date.now() - t0}ms${res.writableFinished ? '' : ' (aborted)'}`);
|
||
});
|
||
next();
|
||
});
|
||
}
|
||
|
||
app.get('/api/config', (req, res) => {
|
||
res.json({
|
||
videoDir: VIDEO_DIR,
|
||
cacheDir: CACHE_DIR,
|
||
qualities: config.qualities,
|
||
encoder: { modes: transcoder.modes, label: MODE_LABEL[transcoder.modes[0]] },
|
||
});
|
||
});
|
||
|
||
app.get('/api/videos', async (req, res) => {
|
||
res.json(await listVideos());
|
||
});
|
||
|
||
app.get('/api/jobs', (req, res) => res.json(transcoder.list()));
|
||
|
||
/**
|
||
* Body: { name, quality: "1920" } | { name, qualities: ["2560","1920"] } | { name, all: true }
|
||
* `all` = every applicable quality that is not ready yet, produced in one pass.
|
||
*/
|
||
app.post('/api/transcode', async (req, res) => {
|
||
const { name, quality, qualities, all } = req.body || {};
|
||
if (!isSafeName(name)) return res.status(400).json({ error: '檔名不合法' });
|
||
let qids;
|
||
if (all) {
|
||
const v = await describeVideo(name);
|
||
qids = Object.entries(v.variants).filter(([, s]) => !s.ready).map(([id]) => id);
|
||
if (!qids.length) return res.status(409).json({ error: '所有畫質都已經轉檔完成' });
|
||
} else {
|
||
qids = Array.isArray(qualities) ? qualities.map(String) : [String(quality)];
|
||
if (!qids.length || qids.some(q => !QUALITY_IDS.has(q))) return res.status(400).json({ error: '未知的畫質' });
|
||
}
|
||
res.json(await transcoder.enqueue(name, qids));
|
||
});
|
||
|
||
app.delete('/api/jobs/finished', (req, res) => { transcoder.clearFinished(); res.json({ ok: true }); });
|
||
app.delete('/api/jobs/:id', (req, res) => res.json(transcoder.cancel(req.params.id)));
|
||
|
||
app.delete('/api/variant', async (req, res) => {
|
||
const { name, quality } = req.query;
|
||
if (!isSafeName(name)) return res.status(400).json({ error: '檔名不合法' });
|
||
if (!QUALITY_IDS.has(String(quality))) return res.status(400).json({ error: '未知的畫質' });
|
||
await transcoder.deleteVariant(name, String(quality));
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
// ---- Server-sent events: job progress pushed to every open page ----
|
||
const sseClients = new Set();
|
||
let sseTimer = null;
|
||
function broadcast(type, data) {
|
||
const payload = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
|
||
for (const c of sseClients) c.write(payload);
|
||
}
|
||
transcoder.on('update', () => {
|
||
if (sseTimer) return; // throttle progress spam to ~4/s
|
||
sseTimer = setTimeout(() => { sseTimer = null; broadcast('jobs', transcoder.list()); }, 250);
|
||
});
|
||
transcoder.on('finished', job => broadcast('finished', job));
|
||
|
||
app.get('/api/events', (req, res) => {
|
||
res.writeHead(200, {
|
||
'Content-Type': 'text/event-stream',
|
||
'Cache-Control': 'no-cache',
|
||
Connection: 'keep-alive',
|
||
'X-Accel-Buffering': 'no',
|
||
});
|
||
res.write('retry: 2000\n\n');
|
||
res.write(`event: jobs\ndata: ${JSON.stringify(transcoder.list())}\n\n`);
|
||
sseClients.add(res);
|
||
const ping = setInterval(() => res.write(': ping\n\n'), 20000);
|
||
req.on('close', () => { clearInterval(ping); sseClients.delete(res); });
|
||
});
|
||
|
||
// ---- Media streaming with HTTP Range support ----
|
||
const MIME = { '.mp4': 'video/mp4', '.m4v': 'video/mp4', '.mov': 'video/mp4', '.webm': 'video/webm' };
|
||
app.get('/stream/:name', async (req, res) => {
|
||
const name = req.params.name;
|
||
if (!isSafeName(name)) return res.status(400).end();
|
||
const q = String(req.query.q || 'original');
|
||
let file;
|
||
if (q === 'original') file = path.join(VIDEO_DIR, name);
|
||
else if (QUALITY_IDS.has(q)) file = transcoder.variantPath(name, q);
|
||
else return res.status(400).end();
|
||
try { await fs.access(file); } catch { return res.status(404).end(); }
|
||
|
||
res.sendFile(file, {
|
||
acceptRanges: true,
|
||
cacheControl: false,
|
||
etag: false,
|
||
lastModified: true,
|
||
dotfiles: 'allow',
|
||
headers: {
|
||
'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream',
|
||
'Cache-Control': 'no-cache',
|
||
},
|
||
}, err => {
|
||
// Client aborts (seeking, closing the tab) surface here; nothing to do.
|
||
if (err && !res.headersSent && err.code !== 'ECONNABORTED') res.status(err.status || 500).end();
|
||
});
|
||
});
|
||
|
||
app.use('/vendor/three', express.static(path.join(__dirname, 'node_modules/three/build'), { maxAge: '1d' }));
|
||
app.use(express.static(path.join(__dirname, 'public')));
|
||
|
||
app.use((err, req, res, next) => {
|
||
if (res.headersSent) return next(err);
|
||
const status = err.status || 500;
|
||
if (status >= 500) console.error(err);
|
||
res.status(status).json({ error: err.message || 'server error' });
|
||
});
|
||
|
||
// ---- Start ----
|
||
// ---- TLS (optional) ----
|
||
// Strip CRLF and guarantee a trailing newline, otherwise cert and chain glue together
|
||
// into one line when concatenated and the PEM no longer parses.
|
||
const normalizePem = (s) => s.replace(/\r\n/g, '\n').replace(/\n*$/, '\n');
|
||
|
||
/**
|
||
* Build a secure context from CERT_DIR. Intermediates belong in `cert`, not `ca`:
|
||
* Node only sends what is in `cert`, while `ca` is the trust store used to verify
|
||
* peers. Putting the chain in `ca` yields an incomplete chain for some clients.
|
||
*/
|
||
async function loadTls() {
|
||
const read = (n) => fs.readFile(path.join(CERT_DIR, n), 'utf8');
|
||
const [cert, key] = await Promise.all([read(CERT_FILES.cert), read(CERT_FILES.key)]);
|
||
let chain = '';
|
||
try { chain = await read(CERT_FILES.chain); } catch { /* chain is optional */ }
|
||
const x = new X509Certificate(cert);
|
||
const days = Math.round((new Date(x.validTo) - Date.now()) / 86400000);
|
||
return {
|
||
context: { cert: normalizePem(cert) + (chain ? normalizePem(chain) : ''), key },
|
||
note: `${x.subject.replace(/^CN=/, '')} — ` +
|
||
(days < 0 ? `⚠ 已於 ${x.validTo} 過期` : `${days} 天後到期`),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Certificates are mounted from outside and DSM overwrites them on renewal.
|
||
* setSecureContext swaps them in place: restarting would kill a transcode that
|
||
* may have been running for hours.
|
||
*/
|
||
function watchCert(srv) {
|
||
let timer = null;
|
||
try {
|
||
fsSync.watch(CERT_DIR, () => {
|
||
clearTimeout(timer);
|
||
// Renewal rewrites three files; wait for the burst to settle, then apply once.
|
||
timer = setTimeout(async () => {
|
||
try {
|
||
const tls = await loadTls();
|
||
srv.setSecureContext(tls.context);
|
||
console.log(`憑證已重新載入:${tls.note}`);
|
||
} catch (e) {
|
||
console.error(`憑證重新載入失敗:${e.message}`);
|
||
}
|
||
}, 1000);
|
||
});
|
||
} catch (e) {
|
||
console.warn(`無法監看憑證資料夾(${e.message}),續期後需手動重啟容器`);
|
||
}
|
||
}
|
||
|
||
await probeCache.load();
|
||
const modes = await transcoder.init();
|
||
|
||
// A missing or broken certificate must not stop the player from serving over HTTP.
|
||
let httpsServer = null;
|
||
let tlsNote = '未啟用';
|
||
if (config.httpsPort) {
|
||
try {
|
||
const tls = await loadTls();
|
||
httpsServer = https.createServer(tls.context, app);
|
||
httpsServer.keepAliveTimeout = 65000;
|
||
await new Promise((resolve, reject) => {
|
||
httpsServer.once('error', reject);
|
||
httpsServer.listen(config.httpsPort, config.host, resolve);
|
||
});
|
||
watchCert(httpsServer);
|
||
tlsNote = tls.note;
|
||
} catch (e) {
|
||
httpsServer = null;
|
||
tlsNote = `⚠ 讀不到憑證(${CERT_DIR}):${e.code || e.message},只提供 HTTP`;
|
||
}
|
||
}
|
||
|
||
const server = app.listen(config.port, config.host, () => {
|
||
const proto = httpsServer ? 'https' : 'http';
|
||
const port = httpsServer ? config.httpsPort : config.port;
|
||
console.log(`360 Player`);
|
||
console.log(` 影片資料夾 : ${VIDEO_DIR}`);
|
||
console.log(` 轉檔快取 : ${CACHE_DIR}`);
|
||
console.log(` 轉檔引擎 : ${MODE_LABEL[modes[0]]} (備援: ${modes.slice(1).map(m => MODE_LABEL[m]).join(' → ') || '無'})`);
|
||
console.log(` HTTPS : ${tlsNote}`);
|
||
console.log(` 網址 : ${proto}://localhost:${port}`);
|
||
for (const [ifname, addrs] of Object.entries(os.networkInterfaces())) {
|
||
for (const a of addrs) {
|
||
if (a.family === 'IPv4' && !a.internal) console.log(` ${proto}://${a.address}:${port} (${ifname})`);
|
||
}
|
||
}
|
||
});
|
||
server.keepAliveTimeout = 65000;
|
||
|
||
// Graceful stop (docker stop / Ctrl+C): kill the running ffmpeg so no half-written .part survives.
|
||
for (const sig of ['SIGTERM', 'SIGINT']) {
|
||
process.on(sig, () => {
|
||
console.log(`收到 ${sig},關閉中…`);
|
||
if (transcoder.running) transcoder.cancel(transcoder.running.id);
|
||
let pending = httpsServer ? 2 : 1;
|
||
const done = () => { if (--pending === 0) process.exit(0); };
|
||
server.close(done);
|
||
httpsServer?.close(done);
|
||
setTimeout(() => process.exit(0), 3000).unref();
|
||
});
|
||
}
|