feat(media): history materialize、LRU 與 LAN URL
.env.exampleDocs/MEDIA_CACHE.mdDocs/TEST_FILES_REFERENCE.mdScriptLib/media/cache.cjsScriptLib/media/inline.cjsScriptLib/monitor/store.cjsServer.jsTest/media_cache.test.cjsTest/monitor_history.test.cjstodo.md
commit 89b0329df934316d5d677924346d62c08a12c5f1Author: Nuclear0709 <agp05432@gmail.com>Date: Wed Nov 11 00:31:15 2026 +0800 feat(media): history materialize、LRU 與 LAN URL - monitor 歷史把 data URI materialize 成 /media,SQLite 不存 base64 - inline 與歷史引用的 HTTP 圖片 pin,LRU 不誤刪;新增容量/天數 prune - Media base 預設使用私有 LAN IPv4,MEDIA_BASE_URL 可覆寫 - 補 media cache、monitor history 與 LAN 偵測測試 diff --git a/.env.example b/.env.example--- a/.env.example+++ b/.env.example@@ -127,8 +127,11 @@ SOCKET_API=http://192.168.0.101:9322 # ─── 圖片快取 ──────────────────────────────────────── # TikTok/Twitch 頭像、禮物圖與贊助 overlay 圖片的本地快取 URL 前綴。-# 跨機 overlay 請改成 http://<主服務區網IP>:3332-# MEDIA_BASE_URL=http://127.0.0.1:3332+# 預設自動挑目前私有 LAN IPv4(例如 192.168.x.x);需要時才覆寫。+# MEDIA_BASE_URL=http://192.168.0.102:3332+# 非 pinned HTTP 快取容量與天數;inline/歷史引用會 pin。+# MEDIA_CACHE_MAX_MB=1024+# MEDIA_CACHE_DAYS=90 # 贊助廣告管理頁面網址(Bark 通知點擊後開啟) # SPONSOR_MANAGE_URL=http://你的內網IP:3332/sponsordiff --git a/Docs/MEDIA_CACHE.md b/Docs/MEDIA_CACHE.md--- a/Docs/MEDIA_CACHE.md+++ b/Docs/MEDIA_CACHE.md@@ -17,6 +17,8 @@ MediaCache 將圖片持久化到本機,訊息只傳本地 URL: → 第一次請求時下載並寫入本機 ``` +主服務綁定 `0.0.0.0:3332`;Media URL 預設使用目前私有 IPv4(優先 192.168.x.x),不再使用 `127.0.0.1`,讓同網域的其他機器能取得圖片。只有找不到私有 IPv4 時才退回 loopback。+ ## 檔案 - `ScriptLib/media/cache.cjs`:快取、下載、SSRF 檢查與供應。@@ -39,10 +41,11 @@ MediaCache 將圖片持久化到本機,訊息只傳本地 URL: `img`、`giftImg`、`message` 內嵌圖片、`AdOverlay.iconURL`、贊助橫幅與通知圖片都接受 png/jpeg/webp/gif data URI。 -- MediaCache 只處理 HTTP(S);data URI 原樣直傳,不抓取、不寫 DB。+- MediaCache 一般只處理 HTTP(S);data URI 在 live 路徑原樣直傳,不抓取。只有 monitor 歷史會把 data URI materialize 成 content-addressed 本機檔。 - `ScriptLib/media/inline.cjs` 驗證格式、encoded 大小、數量與總量;不切片、不 fallback HTTP。 - 通知會移除 body 中的 data URI,且外部 Bark/ntfy 不帶 data URI icon。-- Live 顯示保留 inline;歷史持久化不應直接寫 base64,必要時 materialize 成 `/media/<content-hash>`。+- Live 顯示保留 inline;monitor 歷史會 materialize 成 `/media/<content-hash>`,SQLite 只存本地 URL,不存 base64。+- inline 與歷史引用的 HTTP 圖片會 pin,一般 LRU 不會刪掉;歷史場次/訊息被修剪後才釋放引用。 - Python overlay 已在 texture worker 本機解碼 png/jpeg/webp/gif data URI;日誌只記錄類型與長度。 ## 檢視與手動測試@@ -68,9 +71,11 @@ DNS rebinding 仍是殘餘風險;若來源不需要任意主機,之後可加 | 變數 | 預設 | 說明 | | --- | --- | --- |-| `MEDIA_BASE_URL` | `http://127.0.0.1:3332` | 回傳給 overlay 的本地 URL 前綴;跨機請改區網 IP |+| `MEDIA_BASE_URL` | 自動挑私有 LAN IPv4(例如 `http://192.168.0.102:3332`) | 回傳給 overlay 的本地 URL 前綴;有多張網卡或要反向代理時再覆寫 |+| `MEDIA_CACHE_MAX_MB` | `1024` | 非 pinned HTTP 快取容量上限 |+| `MEDIA_CACHE_DAYS` | `90` | 非 pinned HTTP 快取保留天數 | -目前沒有自動 LRU/總容量上限;Phase 2 再補保留期限、容量與清理介面。+主服務每小時 prune 一次;inline 與 monitor 歷史引用的檔案是 pinned,不會被一般 LRU 刪除。歷史修剪後會解除 HTTP pin,inline 則直接刪檔。 ## 驗證 diff --git a/Docs/TEST_FILES_REFERENCE.md b/Docs/TEST_FILES_REFERENCE.md--- a/Docs/TEST_FILES_REFERENCE.md+++ b/Docs/TEST_FILES_REFERENCE.md@@ -58,11 +58,11 @@ node Test/xxx.mjs | `Test/runtime_ui.cjs` | 運行狀態頁的狀態卡、聊天來源 badge、manual/browser 顯示、重新檢查/停止 toast、390px 無溢出;輸出 `runtime-desktop.png`、`runtime-mobile.png` | | `Test/audience_ui.cjs` | 常客關係 badge/avatar、關係篩選、總頁數、分數組成、等級門檻、最近場次與手機寬度 | | `Test/monitor_audience_ui.cjs` | 監控聊天列、本場統計、測試訊息、Socket/通知派發、精確結果與即時/歷史切換 |-| `Test/monitor_history.test.cjs` | 監控歷史 SQLite 的場次、分頁、搜尋、保留與測試訊息排除 |+| `Test/monitor_history.test.cjs` | 監控歷史 SQLite、data URI materialize 與保留/釋放 | | `Test/monitor_routes.test.cjs` | 監控歷史 API 的登入、場次、訊息與缺少場次 | | `Test/navigation_guard.test.mjs` | Signer TikTok 導航最小間隔與 WAF 冷卻 | | `Test/video_tk_ui.cjs` | videoTK 劇院樣式還原、可拖動按鈕與拖曳不誤觸 |-| `Test/media_cache.test.cjs` | 圖片快取本地 URL、下載、SVG/私有位址拒絕、路由與 SocketHub 改寫 |+| `Test/media_cache.test.cjs` | 圖片快取、inline pin、LRU/大小上限、SVG/私有位址拒絕、路由與 SocketHub 改寫 | | `Test/inline_image.test.cjs` | data URI 圖片格式、大小/數量限制與通知剝離 | | `Test/socket_test_routes.test.cjs` | Raw Socket 測試入口的登入、子程序檢查與 payload 轉送 | | `Test/diagnostics.test.cjs` | 診斷 store、`/post`、diagnostic、token、Socket 外部事件訂閱與 API |diff --git a/ScriptLib/media/cache.cjs b/ScriptLib/media/cache.cjs--- a/ScriptLib/media/cache.cjs+++ b/ScriptLib/media/cache.cjs@@ -4,7 +4,9 @@ const path = require('node:path'); const crypto = require('node:crypto'); const dns = require('node:dns/promises'); const net = require('node:net');+const os = require('node:os'); const { DatabaseSync } = require('node:sqlite');+const inlineMedia = require('./inline.cjs'); const ROOT = path.resolve(__dirname, '../..'); const DEFAULT_DB = path.join(ROOT, 'data/media.sqlite');@@ -18,6 +20,26 @@ const ALLOWED_TYPES = new Set([ const hashUrl = value => crypto.createHash('sha256').update(value).digest('hex'); +/**+ * Media URL 給其他機器使用,預設挑目前區網私有 IPv4,而不是 localhost。+ * MEDIA_BASE_URL 可明確覆寫(例如反向代理或固定 hostname)。+ */+function detectMediaBaseUrl({ interfaces = os.networkInterfaces(), port = 3332, env = process.env } = {}) {+ const explicit = String(env.MEDIA_BASE_URL || '').trim();+ if (explicit) return explicit.replace(/\/+$/, '');+ const candidates = [];+ for (const addresses of Object.values(interfaces || {})) {+ for (const item of addresses || []) {+ if (![4, 'IPv4'].includes(item.family) || item.internal || !isPrivateAddress(item.address)) continue;+ candidates.push(item.address);+ }+ }+ const rank = address => address.startsWith('192.168.') ? 0+ : address.startsWith('10.') ? 1 : 2;+ candidates.sort((a, b) => rank(a) - rank(b));+ return `http://${candidates[0] || '127.0.0.1'}:${Number(port) || 3332}`;+}+ /** URL 只接受一般 HTTP(S),避免 data/blob/file 或帳密進入快取。 */ function normalizeImageUrl(value) { const raw = String(value || '').trim();@@ -58,14 +80,18 @@ function isPrivateAddress(address) { * 只存圖片位元組與來源 metadata,不存 HTML。 */ class MediaCache {- constructor({ dbFile = DEFAULT_DB, imageDir = DEFAULT_DIR, baseUrl = process.env.MEDIA_BASE_URL || 'http://127.0.0.1:3332',- fetchImpl = fetch, allowPrivateHosts = false, now = Date.now } = {}) {+ constructor({ dbFile = DEFAULT_DB, imageDir = DEFAULT_DIR, baseUrl = detectMediaBaseUrl(),+ fetchImpl = fetch, allowPrivateHosts = false, now = Date.now,+ maxBytes = (Number(process.env.MEDIA_CACHE_MAX_MB) || 1024) * 1024 * 1024,+ maxAgeMs = (Number(process.env.MEDIA_CACHE_DAYS) || 90) * 86400000 } = {}) { this.dbFile = dbFile; this.imageDir = imageDir; this.baseUrl = String(baseUrl || '').replace(/\/+$/, ''); this.fetchImpl = fetchImpl; this.allowPrivateHosts = allowPrivateHosts; this.now = now;+ this.maxBytes = Math.max(0, Number(maxBytes) || 0);+ this.maxAgeMs = Math.max(0, Number(maxAgeMs) || 0); this.inflight = new Map(); fs.mkdirSync(path.dirname(dbFile), { recursive: true }); fs.mkdirSync(imageDir, { recursive: true });@@ -83,10 +109,14 @@ class MediaCache { fetched_at INTEGER, last_access INTEGER, fail_count INTEGER NOT NULL DEFAULT 0,- last_fail_at INTEGER+ last_fail_at INTEGER,+ source TEXT NOT NULL DEFAULT 'http',+ pinned INTEGER NOT NULL DEFAULT 0 ); `);- this.known = new Set(this.db.prepare('SELECT hash FROM media').all().map(row => row.hash));+ const columns = new Set(this.db.prepare('PRAGMA table_info(media)').all().map(row => row.name));+ if (!columns.has('source')) this.db.exec("ALTER TABLE media ADD COLUMN source TEXT NOT NULL DEFAULT 'http'");+ if (!columns.has('pinned')) this.db.exec('ALTER TABLE media ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0'); } filePath(hash) {@@ -100,9 +130,8 @@ class MediaCache { if (url.startsWith(this.baseUrl + '/media/')) return url; const hash = hashUrl(url); try {- this.db.prepare(`INSERT INTO media(hash,url,last_access) VALUES(?,?,?)- ON CONFLICT(hash) DO UPDATE SET url=excluded.url`).run(hash, url, this.now());- this.known.add(hash);+ this.db.prepare(`INSERT INTO media(hash,url,last_access,source,pinned) VALUES(?,?,?,?,?)+ ON CONFLICT(hash) DO UPDATE SET url=excluded.url`).run(hash, url, this.now(), 'http', 0); this.warm(hash, url); } catch { return value;@@ -110,11 +139,76 @@ class MediaCache { return `${this.baseUrl}/media/${hash}`; } + /** 背景預抓 HTTP 圖片;失敗不影響原訊息。 */ warm(hash, url) { if (this.inflight.has(hash) || fs.existsSync(this.filePath(hash))) return; this.ensure(hash, url).catch(() => {}); } + /**+ * 把已驗證的 data URI 解碼為 content-addressed 檔案;只供歷史持久化使用。+ * 回傳本地 URL,不把 base64 寫進 SQLite。+ */+ storeInlineDataUri(value) {+ const decoded = inlineMedia.decodeDataImage(value);+ if (!decoded.ok) return null;+ const file = this.filePath(decoded.hash);+ if (!fs.existsSync(file)) {+ fs.mkdirSync(this.imageDir, { recursive: true });+ const temporary = file + '.' + process.pid + '.tmp';+ fs.writeFileSync(temporary, decoded.body);+ fs.renameSync(temporary, file);+ }+ this.db.prepare(`INSERT INTO media(hash,url,content_type,byte_size,etag,fetched_at,last_access,source,pinned)+ VALUES(?,?,?,?,?,?,?,'inline',1)+ ON CONFLICT(hash) DO UPDATE SET content_type=excluded.content_type,byte_size=excluded.byte_size,+ source='inline',pinned=1,last_access=excluded.last_access`).run(+ decoded.hash, 'inline:' + decoded.hash, decoded.mime, decoded.body.length,+ decoded.hash, this.now(), this.now()+ );+ return `${this.baseUrl}/media/${decoded.hash}`;+ }++ /** 讓 monitor 歷史引用的 HTTP 快取不被 LRU 清掉。 */+ pin(hash) {+ if (!/^[a-f0-9]{64}$/.test(String(hash || ''))) return;+ this.db.prepare('UPDATE media SET pinned=1 WHERE hash=?').run(hash);+ }++ /** 歷史不再引用時解除 HTTP pin;inline 由 releaseInlineExcept 處理。 */+ unpinExcept(referenced = new Set()) {+ const rows = this.db.prepare("SELECT hash FROM media WHERE pinned=1 AND source='http'").all();+ for (const row of rows) {+ if (!referenced.has(row.hash)) this.db.prepare('UPDATE media SET pinned=0 WHERE hash=?').run(row.hash);+ }+ }++ /** 釋放不再被 monitor 歷史引用的 inline 檔。 */+ releaseInlineExcept(referenced = new Set()) {+ const rows = this.db.prepare("SELECT hash FROM media WHERE source='inline'").all();+ for (const row of rows) {+ if (referenced.has(row.hash)) continue;+ try { fs.unlinkSync(this.filePath(row.hash)); } catch { /* ignore */ }+ this.db.prepare('DELETE FROM media WHERE hash=?').run(row.hash);+ }+ }++ /** 清除過期或超出容量的非 pinned HTTP 快取;inline 由引用方管理。 */+ prune() {+ const rows = this.db.prepare(`SELECT hash,byte_size,last_access FROM media+ WHERE pinned=0 ORDER BY last_access ASC, hash ASC`).all();+ let total = rows.reduce((sum, row) => sum + (Number(row.byte_size) || 0), 0);+ const cutoff = this.now() - this.maxAgeMs;+ for (const row of rows) {+ const expired = this.maxAgeMs > 0 && Number(row.last_access || 0) < cutoff;+ if (!expired && total <= this.maxBytes) continue;+ try { fs.unlinkSync(this.filePath(row.hash)); } catch { /* ignore */ }+ this.db.prepare('DELETE FROM media WHERE hash=?').run(row.hash);+ total -= Number(row.byte_size) || 0;+ }+ return { remainingBytes: total };+ }+ async assertPublicUrl(url) { if (this.allowPrivateHosts) return; const host = new URL(url).hostname;@@ -248,6 +342,8 @@ class MediaCache { fetchedAt: row.fetched_at || null, lastAccess: row.last_access || null, failCount: row.fail_count || 0,+ source: row.source || 'http',+ pinned: row.pinned === 1, status: this.statusOf(row) }; }@@ -290,4 +386,4 @@ class MediaCache { const defaultMediaCache = new MediaCache(); -module.exports = { MediaCache, defaultMediaCache, hashUrl, normalizeImageUrl, isPrivateAddress };+module.exports = { MediaCache, defaultMediaCache, hashUrl, normalizeImageUrl, isPrivateAddress, detectMediaBaseUrl };diff --git a/ScriptLib/media/inline.cjs b/ScriptLib/media/inline.cjs--- a/ScriptLib/media/inline.cjs+++ b/ScriptLib/media/inline.cjs@@ -2,6 +2,7 @@ * Inline data URI 圖片驗證。這不是 URL 快取:data URI 由 App 本機解碼, * 伺服器只檢查格式與 encoded 大小,不抓取、不切片、不改寫。 */+const crypto = require('node:crypto'); const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']); const MAX_IMAGE_BYTES = 256 * 1024; const MAX_TOTAL_BYTES = 768 * 1024;@@ -28,6 +29,20 @@ function parseDataImage(value) { return { ok: true, mime, encodedBytes, payload: match[2] }; } +/** 解碼已驗證的 data URI;供歷史 materialize 使用,不做外部請求。 */+function decodeDataImage(value) {+ const parsed = parseDataImage(value);+ if (!parsed.ok) return parsed;+ if (parsed.encodedBytes > MAX_IMAGE_BYTES) return { ok: false, reason: 'image-too-large' };+ const payload = parsed.payload + '='.repeat((-parsed.payload.length) % 4);+ const body = Buffer.from(payload, 'base64');+ if (!body.length) return { ok: false, reason: 'empty-image' };+ return {+ ok: true, mime: parsed.mime, body,+ hash: crypto.createHash('sha256').update(body).digest('hex')+ };+}+ /** 從文字抽出所有 data image URI(包含不支援的 MIME,讓驗證層回報)。 */ function extractDataImages(text) { if (typeof text !== 'string') return [];@@ -73,5 +88,5 @@ function validateInlineImages({ img = '', giftImg = '', message = '' } = {}) { module.exports = { ALLOWED_MIME, MAX_IMAGE_BYTES, MAX_TOTAL_BYTES, MAX_COUNT,- isInlineImage, parseDataImage, extractDataImages, stripInlineImages, validateInlineImages+ isInlineImage, parseDataImage, decodeDataImage, extractDataImages, stripInlineImages, validateInlineImages };diff --git a/ScriptLib/monitor/store.cjs b/ScriptLib/monitor/store.cjs--- a/ScriptLib/monitor/store.cjs+++ b/ScriptLib/monitor/store.cjs@@ -2,8 +2,10 @@ const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); const { DatabaseSync } = require('node:sqlite');+const inlineMedia = require('../media/inline.cjs'); const DAY = 86400000;+const INLINE_IMAGE_RE = /data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/]+={0,2}/gi; /** * 直播訊息歷史:每個子程序啟動是一場,訊息以 SQLite 保存,供重啟後回顧。@@ -17,14 +19,16 @@ class MonitorHistoryStore { * @param {number} [options.maxSessions=10] * @param {number} [options.maxMessagesPerSession=20000] * @param {() => number} [options.now]+ * @param {Object} [options.mediaCache] MediaCache;提供時把 data URI materialize 成 /media。 */ constructor({ file = path.join(__dirname, '../../data/monitor.sqlite'), retentionDays = 14,- maxSessions = 10, maxMessagesPerSession = 20000, now = Date.now } = {}) {+ maxSessions = 10, maxMessagesPerSession = 20000, now = Date.now, mediaCache = null } = {}) { this.file = file; this.retentionDays = Math.max(1, retentionDays); this.maxSessions = Math.max(1, maxSessions); this.maxMessagesPerSession = Math.max(1, maxMessagesPerSession); this.now = now;+ this.mediaCache = mediaCache; this.appended = 0; if (file !== ':memory:') fs.mkdirSync(path.dirname(file), { recursive: true }); this.db = new DatabaseSync(file);@@ -56,10 +60,17 @@ class MonitorHistoryStore { ); CREATE INDEX IF NOT EXISTS messages_session_t ON messages(session_id, t, id); CREATE INDEX IF NOT EXISTS messages_t ON messages(t);+ CREATE TABLE IF NOT EXISTS message_media (+ message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,+ hash TEXT NOT NULL,+ PRIMARY KEY(message_id, hash)+ );+ CREATE INDEX IF NOT EXISTS message_media_hash ON message_media(hash); `); // 主服務重啟時,上一個子程序已不在;把殘留的開放場次收尾。 this.db.prepare('UPDATE sessions SET ended_at=COALESCE(last_message_at,started_at) WHERE ended_at IS NULL').run(); this.prune();+ this.syncInlineReferences(); } startSession({ startedAt = this.now() } = {}) {@@ -74,6 +85,71 @@ class MonitorHistoryStore { this.db.prepare('UPDATE sessions SET ended_at=? WHERE id=? AND ended_at IS NULL').run(Number(endedAt), id); } + collectLocalHashes(value, hashes) {+ if (typeof value !== 'string') return;+ for (const match of value.matchAll(/\/media\/([a-f0-9]{64})/g)) hashes.add(match[1]);+ }++ storeInline(value, hashes) {+ if (!this.mediaCache || typeof value !== 'string' || !inlineMedia.isInlineImage(value)) return value;+ try {+ const local = this.mediaCache.storeInlineDataUri(value);+ const hash = /\/media\/([a-f0-9]{64})$/.exec(local || '')?.[1];+ if (hash) {+ hashes.add(hash);+ return local;+ }+ } catch { /* fall through */ }+ return '';+ }++ materializeMessage(message, hashes) {+ const dataUris = inlineMedia.extractDataImages(message);+ if (!dataUris.length) return message;+ if (!this.mediaCache) return inlineMedia.stripInlineImages(message) || '[inline image]';+ if (!inlineMedia.validateInlineImages({ message }).ok) {+ return message.replace(INLINE_IMAGE_RE, '[inline image]');+ }+ let result = message;+ for (const dataUri of dataUris) {+ const local = this.storeInline(dataUri, hashes);+ result = result.replace(dataUri, local || '[inline image]');+ }+ return result;+ }++ /** 只影響寫入歷史的副本;live socket/SSE 仍保留原始 data URI。 */+ prepareEntry(entry) {+ const hashes = new Set();+ const validation = inlineMedia.validateInlineImages({+ img: entry.img, giftImg: entry.giftImg, message: entry.message+ });+ if (!validation.ok) {+ return {+ message: String(entry.message).replace(INLINE_IMAGE_RE, '[inline image]'),+ img: inlineMedia.isInlineImage(entry.img) ? '' : entry.img,+ giftImg: inlineMedia.isInlineImage(entry.giftImg) ? '' : entry.giftImg,+ hashes+ };+ }+ return {+ message: this.materializeMessage(String(entry.message), hashes),+ img: this.storeInline(entry.img, hashes),+ giftImg: this.storeInline(entry.giftImg, hashes),+ hashes+ };+ }++ /** 清掉 monitor 已不再引用的 inline media;由場次/訊息修剪後呼叫。 */+ syncInlineReferences() {+ if (!this.mediaCache || typeof this.mediaCache.releaseInlineExcept !== 'function') return;+ const referenced = new Set(+ this.db.prepare('SELECT DISTINCT hash FROM message_media').all().map(row => row.hash)+ );+ this.mediaCache.releaseInlineExcept(referenced);+ if (typeof this.mediaCache.unpinExcept === 'function') this.mediaCache.unpinExcept(referenced);+ }+ /** * @param {string} sessionId * @param {Object} entry 監控 payload。@@ -87,14 +163,25 @@ class MonitorHistoryStore { const stats = entry.session && typeof entry.session === 'object' ? entry.session : {}; const audience = entry.audience && typeof entry.audience === 'object' ? JSON.stringify(entry.audience) : null;+ const prepared = this.prepareEntry(entry);+ // 已是 /media/<hash> 的 HTTP 圖片也要 pin,避免 LRU 刪掉歷史還在用的檔。+ for (const value of [prepared.message, prepared.img, prepared.giftImg]) {+ this.collectLocalHashes(value, prepared.hashes);+ }+ if (this.mediaCache && typeof this.mediaCache.pin === 'function') {+ for (const hash of prepared.hashes) this.mediaCache.pin(hash);+ } this.db.exec('BEGIN IMMEDIATE'); try { this.db.prepare(`INSERT INTO messages(session_id,t,category,platform,user,message,img,gift_img,audience) VALUES(?,?,?,?,?,?,?,?,?)`).run( sessionId, t,- entry.category ?? null, entry.platform ?? null, entry.user ?? null, entry.message,- entry.img ?? null, entry.giftImg ?? null, audience+ entry.category ?? null, entry.platform ?? null, entry.user ?? null, prepared.message,+ prepared.img ?? null, prepared.giftImg ?? null, audience );+ const messageId = Number(this.db.prepare('SELECT last_insert_rowid() AS id').get().id);+ const insertMedia = this.db.prepare('INSERT OR IGNORE INTO message_media(message_id,hash) VALUES(?,?)');+ for (const hash of prepared.hashes) insertMedia.run(messageId, hash); this.db.prepare(`UPDATE sessions SET last_message_at=?, message_count=message_count+1, joins=MAX(joins, ?), likes=MAX(likes, ?), chats=MAX(chats, ?)@@ -102,6 +189,7 @@ class MonitorHistoryStore { this.db.exec('COMMIT'); } catch (error) { try { this.db.exec('ROLLBACK'); } catch { /* ignore */ }+ this.syncInlineReferences(); throw error; } this.appended += 1;@@ -119,6 +207,7 @@ class MonitorHistoryStore { SELECT id FROM messages WHERE session_id=? ORDER BY t ASC, id ASC LIMIT ? )`).run(sessionId, removed); this.db.prepare('UPDATE sessions SET message_count=MAX(0, message_count-?) WHERE id=?').run(removed, sessionId);+ this.syncInlineReferences(); } /** 刪除超過天數或場次上限的歷史。 */@@ -128,6 +217,7 @@ class MonitorHistoryStore { this.db.prepare(`DELETE FROM sessions WHERE id NOT IN ( SELECT id FROM sessions ORDER BY started_at DESC LIMIT ? )`).run(this.maxSessions);+ this.syncInlineReferences(); } getSession(id) {diff --git a/Server.js b/Server.js--- a/Server.js+++ b/Server.js@@ -59,6 +59,7 @@ const audience = createAudienceTracker({ }); const serveAudience = createAudienceRoutes(audience); const mediaRoutes = require('./MediaRoutes.cjs');+const { defaultMediaCache } = require('./ScriptLib/media/cache.cjs'); const serveMedia = mediaRoutes.createMediaRoutes(); const serveMediaAdmin = mediaRoutes.createMediaAdminRoutes(); const { DiagnosticsStore } = require('./ScriptLib/diagnostics/store.cjs');@@ -89,9 +90,14 @@ const { createMonitorHistoryStore } = require('./ScriptLib/monitor/store.cjs'); const monitorHistory = createMonitorHistoryStore({ retentionDays: Number(process.env.MONITOR_HISTORY_DAYS) || 14, maxSessions: Number(process.env.MONITOR_HISTORY_SESSIONS) || 10,- maxMessagesPerSession: Number(process.env.MONITOR_HISTORY_MAX_MESSAGES) || 20000+ maxMessagesPerSession: Number(process.env.MONITOR_HISTORY_MAX_MESSAGES) || 20000,+ mediaCache: defaultMediaCache }); const serveMonitorHistory = require('./MonitorRoutes.cjs').createMonitorRoutes(monitorHistory);+const mediaPrune = setInterval(() => {+ try { defaultMediaCache.prune(); } catch (error) { console.error('[MediaCache] prune 失敗:', error.message); }+}, 3600000);+mediaPrune.unref(); let monitorSessionId = null; let tiktokProcess = null;diff --git a/Test/media_cache.test.cjs b/Test/media_cache.test.cjs--- a/Test/media_cache.test.cjs+++ b/Test/media_cache.test.cjs@@ -5,7 +5,7 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const http = require('node:http');-const { MediaCache, hashUrl, normalizeImageUrl } = require('../ScriptLib/media/cache.cjs');+const { MediaCache, hashUrl, normalizeImageUrl, detectMediaBaseUrl } = require('../ScriptLib/media/cache.cjs'); const { createMediaRoutes, createMediaAdminRoutes } = require('../MediaRoutes.cjs'); const { SocketHub } = require('../ScriptLib/socket/hub.mjs'); @@ -23,6 +23,17 @@ function fakeServe(cache, hash, method = 'GET', headers = {}) { }); } +test('Media base 預設使用私有 LAN IPv4,且允許環境變數覆寫', () => {+ const interfaces = {+ lo: [{ family: 'IPv4', internal: true, address: '127.0.0.1' }],+ wifi: [{ family: 'IPv4', internal: false, address: '192.168.1.20' }],+ other: [{ family: 'IPv4', internal: false, address: '10.0.0.5' }]+ };+ assert.equal(detectMediaBaseUrl({ interfaces, env: {} }), 'http://192.168.1.20:3332');+ assert.equal(detectMediaBaseUrl({ interfaces, env: { MEDIA_BASE_URL: 'http://ttw.local/' } }), 'http://ttw.local');+ assert.equal(detectMediaBaseUrl({ interfaces: { lo: interfaces.lo }, env: {} }), 'http://127.0.0.1:3332');+});+ test('MediaCache 把外部 URL 換成本地 hash,下載後可再從快取供應', async t => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ttw-media-')); const server = http.createServer((req, res) => {@@ -132,6 +143,44 @@ test('MediaRoutes 只接管 /media/<hash> 並呼叫快取', async () => { assert.equal(route({ url: '/other', method: 'GET', headers: {} }, {}), false); }); +test('inline 圖片 pinned,LRU 不刪;釋放引用後才清檔', t => {+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ttw-media-inline-'));+ const cache = new MediaCache({+ dbFile: path.join(dir, 'media.sqlite'), imageDir: path.join(dir, 'images'),+ baseUrl: 'http://media.test', maxBytes: 1+ });+ t.after(() => {+ cache.close();+ fs.rmSync(dir, { recursive: true, force: true });+ });+ const local = cache.storeInlineDataUri('data:image/png;base64,iVBORw0KGgo=');+ assert.match(local, /\/media\/[a-f0-9]{64}$/);+ const hash = local.split('/').pop();+ assert.equal(fs.existsSync(cache.filePath(hash)), true);+ cache.prune();+ assert.equal(fs.existsSync(cache.filePath(hash)), true);+ cache.releaseInlineExcept(new Set());+ assert.equal(fs.existsSync(cache.filePath(hash)), false);+});++test('非 pinned HTTP 快取會依容量上限 prune', t => {+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ttw-media-lru-'));+ const cache = new MediaCache({+ dbFile: path.join(dir, 'media.sqlite'), imageDir: path.join(dir, 'images'),+ baseUrl: 'http://media.test', maxBytes: 1+ });+ t.after(() => {+ cache.close();+ fs.rmSync(dir, { recursive: true, force: true });+ });+ const hash = 'b'.repeat(64);+ fs.writeFileSync(cache.filePath(hash), PNG);+ cache.db.prepare(`INSERT INTO media(hash,url,content_type,byte_size,last_access,source,pinned)+ VALUES(?,?,?,?,?,'http',0)`).run(hash, 'http://example/x.png', 'image/png', PNG.length, Date.now());+ cache.prune();+ assert.equal(fs.existsSync(cache.filePath(hash)), false);+});+ test('SocketHub 送出前改寫 img 與 giftImg', () => { const sent = []; const hub = new SocketHub({diff --git a/Test/monitor_history.test.cjs b/Test/monitor_history.test.cjs--- a/Test/monitor_history.test.cjs+++ b/Test/monitor_history.test.cjs@@ -1,7 +1,11 @@ /** 常客頁以外的監控歷史 SQLite:場次、分頁、篩選、保留與測試訊息排除。 */ const test = require('node:test'); const assert = require('node:assert/strict');+const fs = require('node:fs');+const os = require('node:os');+const path = require('node:path'); const { MonitorHistoryStore } = require('../ScriptLib/monitor/store.cjs');+const { MediaCache } = require('../ScriptLib/media/cache.cjs'); function entry(t, overrides = {}) { return {@@ -79,3 +83,33 @@ test('每場只保留最新訊息且同步 message_count', t => { assert.deepEqual(result.messages.map(item => item.message), ['hello 3', 'hello 2']); assert.equal(store.getSession(session).messageCount, 2); });++test('歷史把 data URI materialize 成 /media,不把 base64 寫進 SQLite', t => {+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ttw-monitor-media-'));+ let now = 1000;+ const cache = new MediaCache({+ dbFile: path.join(dir, 'media.sqlite'), imageDir: path.join(dir, 'images'),+ baseUrl: 'http://media.test', now: () => now+ });+ const store = new MonitorHistoryStore({+ file: path.join(dir, 'monitor.sqlite'), retentionDays: 1, now: () => now, mediaCache: cache+ });+ t.after(() => {+ store.close();+ cache.close();+ fs.rmSync(dir, { recursive: true, force: true });+ });+ const png = 'data:image/png;base64,iVBORw0KGgo=';+ const session = store.startSession({ startedAt: now });+ store.append(session, entry(1001, { message: `look ${png}`, img: png, giftImg: png }));+ const page = store.listMessages({ sessionId: session, page: 1, pageSize: 10 });+ assert.equal(page.messages[0].message.includes('data:image'), false);+ assert.match(page.messages[0].message, /http:\/\/media\.test\/media\/[a-f0-9]{64}/);+ assert.match(page.messages[0].img, /http:\/\/media\.test\/media\//);+ assert.equal(cache.list({ type: 'image/png' }).items[0].source, 'inline');+ assert.equal(fs.readdirSync(path.join(dir, 'images')).length, 1);++ now += 2 * 86400000;+ store.prune();+ assert.equal(fs.readdirSync(path.join(dir, 'images')).length, 0);+});diff --git a/todo.md b/todo.md--- a/todo.md+++ b/todo.md@@ -28,6 +28,7 @@ - [x] 應用診斷 `/diagnostics`、LogServer 相容 `/post` 與可選 token。 - [x] 診斷頁 Socket 外部事件訂閱開關(subscribeExternalLogs、自動重連)。 - [x] 未啟用 YouTube 時不輸出假斷線;handleExit 冪等。+- [x] monitor 歷史 data URI materialize 與 MediaCache LRU/容量上限。 - [ ] 正式環境以實際 child 程序驗證 IPC 結果與倒數(目前 monitor UI 使用假 SSE/fake `/chat`)。 ## 表情映射後續(2026-11-06)