Files
360_player/server.js
T
JianMiauandClaude Opus 5 0a3c548e0b 移除 nginx 容器,改由 Node 直接終結 TLS,合併成單一容器
摘要:
拿掉 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>
2026-08-21 15:09:56 +08:00

324 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
}