feat(media): 圖片快取與本地 URL
.env.example.gitignoreDocs/ChangeHistory.mdDocs/MEDIA_CACHE.mdDocs/RUNTIME.mdDocs/TEST_FILES_REFERENCE.mdMediaRoutes.cjsScriptLib/media/cache.cjsScriptLib/socket/hub.mjsServer.jsTest/media_cache.test.cjsTikTok.jstodo.md
commit b906ee40161741dd5bf5c04b538c256d5d399317Author: Nuclear0709 <agp05432@gmail.com>Date: Tue Nov 10 20:03:10 2026 +0800 feat(media): 圖片快取與本地 URL - 新增持久化 MediaCache、/media/<hash> 路由與 SSRF/型別限制 - SocketHub 改寫 img/giftImg,涵蓋 TikTok/Twitch 等頭像 - AdOverlay icon 與文字圖片改寫為本地快取;通知保留原始 URL - 補 media 測試與 MEDIA_BASE_URL 設定 diff --git a/.env.example b/.env.example--- a/.env.example+++ b/.env.example@@ -116,6 +116,11 @@ SOCKET_API=http://192.168.0.101:9322 # MONITOR_HISTORY_SESSIONS=10 # MONITOR_HISTORY_MAX_MESSAGES=20000 +# ─── 圖片快取 ────────────────────────────────────────+# TikTok/Twitch 頭像、禮物圖與贊助 overlay 圖片的本地快取 URL 前綴。+# 跨機 overlay 請改成 http://<主服務區網IP>:3332+# MEDIA_BASE_URL=http://127.0.0.1:3332+ # 贊助廣告管理頁面網址(Bark 通知點擊後開啟) # SPONSOR_MANAGE_URL=http://你的內網IP:3332/sponsor diff --git a/.gitignore b/.gitignore--- a/.gitignore+++ b/.gitignore@@ -54,3 +54,5 @@ output/playwright/* # changelogkit 匯出的唯讀靜態站(CI 部署 GitHub Pages 產物)。 _site/ /data/monitor.sqlite*+/cache/media/+/data/media.sqlite*diff --git a/Docs/ChangeHistory.md b/Docs/ChangeHistory.md--- a/Docs/ChangeHistory.md+++ b/Docs/ChangeHistory.md@@ -23,6 +23,55 @@ --- +## 2026.11.10 20:00 Inline data URI 驗證與通知剝離++**類型**: 功能 · **檔案**: `ScriptLib/media/inline.cjs`、`ScriptLib/notify/format.mjs`、`Test/inline_image.test.cjs`、`Test/notify.test.mjs`、`Test/media_cache.test.cjs`、`Docs/CHAT_API.md`、`Docs/MEDIA_CACHE.md`、`Docs/TEST_FILES_REFERENCE.md`++### 修改++- 新增 InlineImage 驗證:png/jpeg/webp/gif、base64、單張 256 KiB、總計 768 KiB、最多 20 張;拒絕 SVG/PDF。+- MediaCache 明確 bypass data URI,不快取、不抓取。+- 通知格式化會移除 data URI 內文;Bark/ntfy 不帶 data URI icon;保留 `allowInlineIcon` 給未來支援 inline 的通道。+- 新增 inline 與通知測試;文件補 data URI 契約。++### 尚未處理++- Python overlay 的 `iconURL`/訊息內嵌 data URI 解碼。+- monitor 歷史的 base64 materialize/清理。++### 驗證++- `node --test Test/inline_image.test.cjs Test/notify.test.mjs Test/media_cache.test.cjs`+- `npm run lint`++---++## 2026.11.10 19:47 圖片媒體快取 Phase 1++**類型**: 功能 · **檔案**: `ScriptLib/media/cache.cjs`、`MediaRoutes.cjs`、`Server.js`、`ScriptLib/socket/hub.mjs`、`TikTok.js`、`Test/media_cache.test.cjs`、`.env.example`、`.gitignore`、`Docs/MEDIA_CACHE.md`、`Docs/TEST_FILES_REFERENCE.md`++### 修改++- 新增持久化 MediaCache:`data/media.sqlite`+`cache/media/<sha256>.bin`。+- 訊息中的 `img`/`giftImg` 改成 `/media/<hash>`;涵蓋 TikTok 頭像/禮物圖與 Twitch(及其他平台)頭像。+- 贊助 AdOverlay 的 `iconURL` 與文字內圖片 URL 也改成快取 URL;Bark/ntfy 保留原始 URL。+- 公開 `GET /media/<hash>`,首次請求才下載;支援 ETag、HEAD、immutable cache。+- 下載限制:HTTP(S)、公開 IP、最多 3 次 redirect、8 MB、8 秒、raster 圖片;拒絕 SVG 與私有位址。+- 新增 `MEDIA_BASE_URL`;資料庫與圖片目錄排除 Git。++### 限制++- Phase 1 尚未實作 LRU/容量上限與清理介面。+- DNS rebinding 仍是殘餘風險;可再加 host allowlist。+- 跨機 overlay 必須把 `MEDIA_BASE_URL` 改成主服務區網位址。++### 驗證++- `node --test Test/media_cache.test.cjs`+- `npm run lint`++---+ ## 2026.11.10 05:03 TikTok 訊息處理錯誤隔離 **類型**: 修正 · **檔案**: `ScriptLib/tiktok/events.mjs`、`Test/tiktok_events.test.mjs`diff --git a/Docs/MEDIA_CACHE.md b/Docs/MEDIA_CACHE.md--- /dev/null+++ b/Docs/MEDIA_CACHE.md@@ -0,0 +1,81 @@+# 圖片媒體快取(media)++## 目的++TikTok/Twitch 頭像、TikTok 禮物圖與贊助 overlay 圖片原本直接使用外部 CDN URL。這些 URL 可能:++- 過期或需要重新導向。+- 在 overlay 端重複下載。+- 因來源站限流或網路不穩而閃爍。++MediaCache 將圖片持久化到本機,訊息只傳本地 URL:++```text+外部圖片 URL+ → MediaCache.localUrl(url)+ → http://127.0.0.1:3332/media/<sha256>+ → 第一次請求時下載並寫入本機+```++## 檔案++- `ScriptLib/media/cache.cjs`:快取、下載、SSRF 檢查與供應。+- `MediaRoutes.cjs`:公開的 `GET /media/<sha256>` 路由。+- `cache/media/<sha256>.bin`:圖片位元組。+- `data/media.sqlite`:來源 URL、Content-Type、大小、ETag、存取時間與失敗次數。++`/media/` 只接受 hash,**不提供 `?url=` 任意抓取**。資料庫與圖片目錄已排除 Git。++## 整合位置++- `SocketHub.sendMessage`:統一改寫 `img`/`giftImg`。+ - 涵蓋 TikTok 頭像與禮物圖。+ - Twitch `sendChat` 的頭像也走同一層。+ - YouTube/Odysee 頭像同理。+- `sendAdOverylayMessage`:改寫贊助 `iconURL` 與文字中的圖片 URL。+- Bark/ntfy 通知仍使用原始 URL,因為手機不一定能連到本機快取。++## Data URI++`img`、`giftImg`、`message` 內嵌圖片、`AdOverlay.iconURL`、贊助橫幅與通知圖片都接受 png/jpeg/webp/gif data URI。++- MediaCache 只處理 HTTP(S);data URI 原樣直傳,不抓取、不寫 DB。+- `ScriptLib/media/inline.cjs` 驗證格式、encoded 大小、數量與總量;不切片、不 fallback HTTP。+- 通知會移除 body 中的 data URI,且外部 Bark/ntfy 不帶 data URI icon。+- Live 顯示保留 inline;歷史持久化不應直接寫 base64,必要時 materialize 成 `/media/<content-hash>`。++## 安全限制++- 僅接受 HTTP(S),拒絕 `data:`、`blob:`、`file:` 與含帳密的 URL。+- 下載前解析 DNS,拒絕 loopback、private、link-local 與保留位址。+- 重新導向最多 3 次,每一跳都重新檢查。+- 只接受 raster 圖片:png/jpeg/gif/webp/avif/bmp;拒絕 SVG。+- 最大 8 MB、8 秒 timeout。+- 回應加上 `X-Content-Type-Options: nosniff` 與長效 immutable cache。+- 抓取失敗回 404,不把原始 URL 當 redirect 目標,避免公開路由被拿來探測內網。++DNS rebinding 仍是殘餘風險;若來源不需要任意主機,之後可加 host allowlist。++## 設定++| 變數 | 預設 | 說明 |+| --- | --- | --- |+| `MEDIA_BASE_URL` | `http://127.0.0.1:3332` | 回傳給 overlay 的本地 URL 前綴;跨機請改區網 IP |++目前沒有自動 LRU/總容量上限;Phase 2 再補保留期限、容量與清理介面。++## 驗證++```bash+node --test Test/media_cache.test.cjs+npm run lint+```++## 手動重置++停止主服務與聊天子程序後,刪除:++- `data/media.sqlite*`+- `cache/media/`++下次收到圖片 URL 時會重新建立。diff --git a/Docs/RUNTIME.md b/Docs/RUNTIME.md--- a/Docs/RUNTIME.md+++ b/Docs/RUNTIME.md@@ -33,6 +33,8 @@ Socket 使用既有 SOCKET_API 設定(可再用 SOCKET_APIS 加開多個端點 工具列會顯示子程序維護的本場加入/點讚/聊天累計。數字隨 socket payload 的 `session` metadata 更新;點讚以原始 `data.count` 累加,因此與每位使用者的 60 秒顯示計數不同。點讚的 Bark/ntfy 通知會像加入事件一樣合併 3 秒,monitor 仍保留逐筆點讚列。 +圖片(TikTok/Twitch 頭像、禮物圖與贊助 overlay 圖片)由 MediaCache 持久化到 `cache/media/` 與 `data/media.sqlite`,訊息改傳 `MEDIA_BASE_URL/media/<hash>`;詳見 `Docs/MEDIA_CACHE.md`。+ 歷史訊息保存在 `data/monitor.sqlite`(`sessions`/`messages`)。每次 TikTok 子程序啟動是一場;預設保留最近 10 場、14 天、每場最多 20,000 則,可用 `MONITOR_HISTORY_DAYS`/`MONITOR_HISTORY_SESSIONS`/`MONITOR_HISTORY_MAX_MESSAGES` 調整。測試訊息與 `isTest` 不寫入;歷史 API 需要主服務登入,即時 SSE 仍維持原本公開行為。 monitor 工具列的「即時/歷史」切換會在歷史模式列出最近場次、支援搜尋/分類篩選與分頁;切回即時會恢復背景持續累積的 SSE 訊息。歷史查詢 API 為 `GET /api/monitor/sessions` 與 `GET /api/monitor/messages?session=…&page=…&q=…&categories=…`。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@@ -62,6 +62,8 @@ node Test/xxx.mjs | `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/inline_image.test.cjs` | data URI 圖片格式、大小/數量限制與通知剝離 | | `Test/notify.test.mjs` | 通知格式化與 Bark/ntfy 逐通道結果 | | `Test/traffic_planner_ui.cjs` | 人流分類、批次補分類、預覽/套用/復原與手機寬度 | | `Test/traffic_planner_collecting_ui.cjs` | 資料累積建議、時段分組提醒與一週計畫 UI |diff --git a/MediaRoutes.cjs b/MediaRoutes.cjs--- /dev/null+++ b/MediaRoutes.cjs@@ -0,0 +1,24 @@+const { defaultMediaCache } = require('./ScriptLib/media/cache.cjs');++/**+ * 公開的圖片快取路由:只接受 /media/<sha256>,不提供任意 URL 抓取。+ * @param {import('./ScriptLib/media/cache.cjs').MediaCache} [cache]+ * @returns {Function} 與其他路由相同的同步請求處理器。+ */+function createMediaRoutes(cache = defaultMediaCache) {+ return function serveMedia(req, res) {+ const url = new URL(req.url, 'http://localhost');+ if (!url.pathname.startsWith('/media/')) return false;+ const hash = url.pathname.slice('/media/'.length);+ cache.serve(req, res, hash).catch(error => {+ if (!res.headersSent) {+ res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });+ res.end('Media cache error');+ }+ console.error('[MediaCache] 服務失敗:', error.message);+ });+ return true;+ };+}++module.exports = { createMediaRoutes };diff --git a/ScriptLib/media/cache.cjs b/ScriptLib/media/cache.cjs--- /dev/null+++ b/ScriptLib/media/cache.cjs@@ -0,0 +1,240 @@+const fs = require('node:fs');+const fsp = require('node:fs/promises');+const path = require('node:path');+const crypto = require('node:crypto');+const dns = require('node:dns/promises');+const net = require('node:net');+const { DatabaseSync } = require('node:sqlite');++const ROOT = path.resolve(__dirname, '../..');+const DEFAULT_DB = path.join(ROOT, 'data/media.sqlite');+const DEFAULT_DIR = path.join(ROOT, 'cache/media');+const MAX_BYTES = 8 * 1024 * 1024;+const FETCH_TIMEOUT_MS = 8000;+const CACHE_CONTROL = 'public, max-age=31536000, immutable';+const ALLOWED_TYPES = new Set([+ 'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/avif', 'image/bmp'+]);++const hashUrl = value => crypto.createHash('sha256').update(value).digest('hex');++/** URL 只接受一般 HTTP(S),避免 data/blob/file 或帳密進入快取。 */+function normalizeImageUrl(value) {+ const raw = String(value || '').trim();+ if (!raw || raw.length > 4096) return '';+ let url;+ try { url = new URL(raw); } catch { return ''; }+ if (!['http:', 'https:'].includes(url.protocol)) return '';+ if (!url.hostname || url.username || url.password) return '';+ return url.href;+}++function isPrivateAddress(address) {+ if (net.isIPv4(address)) {+ const parts = address.split('.').map(Number);+ return parts[0] === 0 || parts[0] === 10 || parts[0] === 127+ || (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127)+ || (parts[0] === 169 && parts[1] === 254)+ || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)+ || (parts[0] === 192 && parts[1] === 168)+ || (parts[0] === 192 && parts[1] === 0 && parts[2] === 0)+ || (parts[0] === 198 && (parts[1] === 18 || parts[1] === 19))+ || parts[0] >= 224;+ }+ if (net.isIPv6(address)) {+ const normalized = address.toLowerCase();+ return normalized === '::' || normalized === '::1'+ || normalized.startsWith('fc') || normalized.startsWith('fd')+ || normalized.startsWith('fe8') || normalized.startsWith('fe9')+ || normalized.startsWith('fea') || normalized.startsWith('feb')+ || normalized.startsWith('::ffff:127.') || normalized.startsWith('::ffff:10.')+ || normalized.startsWith('::ffff:192.168.');+ }+ return true;+}++/**+ * 通用圖片快取:把外部圖片 URL 換成本機 /media/<sha256>,首次請求時抓取並持久化。+ * 只存圖片位元組與來源 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 } = {}) {+ this.dbFile = dbFile;+ this.imageDir = imageDir;+ this.baseUrl = String(baseUrl || '').replace(/\/+$/, '');+ this.fetchImpl = fetchImpl;+ this.allowPrivateHosts = allowPrivateHosts;+ this.now = now;+ this.inflight = new Map();+ fs.mkdirSync(path.dirname(dbFile), { recursive: true });+ fs.mkdirSync(imageDir, { recursive: true });+ this.db = new DatabaseSync(dbFile);+ this.db.exec(`+ PRAGMA journal_mode = WAL;+ PRAGMA synchronous = NORMAL;+ PRAGMA busy_timeout = 5000;+ CREATE TABLE IF NOT EXISTS media (+ hash TEXT PRIMARY KEY,+ url TEXT NOT NULL,+ content_type TEXT,+ byte_size INTEGER,+ etag TEXT,+ fetched_at INTEGER,+ last_access INTEGER,+ fail_count INTEGER NOT NULL DEFAULT 0,+ last_fail_at INTEGER+ );+ `);+ this.known = new Set(this.db.prepare('SELECT hash FROM media').all().map(row => row.hash));+ }++ filePath(hash) {+ return path.join(this.imageDir, hash + '.bin');+ }++ /** 同步取得(或建立)本地 URL;預抓失敗不影響原訊息。 */+ localUrl(value) {+ const url = normalizeImageUrl(value);+ if (!url) return value;+ 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.warm(hash, url);+ } catch {+ return value;+ }+ return `${this.baseUrl}/media/${hash}`;+ }++ warm(hash, url) {+ if (this.inflight.has(hash) || fs.existsSync(this.filePath(hash))) return;+ this.ensure(hash, url).catch(() => {});+ }++ async assertPublicUrl(url) {+ if (this.allowPrivateHosts) return;+ const host = new URL(url).hostname;+ if (host === 'localhost' || host.endsWith('.localhost')) throw new Error('不允許本機位址');+ if (net.isIP(host)) {+ if (isPrivateAddress(host)) throw new Error('不允許私有位址');+ return;+ }+ const addresses = await dns.lookup(host, { all: true, verbatim: true });+ if (!addresses.length || addresses.some(item => isPrivateAddress(item.address))) {+ throw new Error('不允許私有網路位址');+ }+ }++ async fetchImage(sourceUrl) {+ let current = sourceUrl;+ for (let redirect = 0; redirect <= 3; redirect++) {+ await this.assertPublicUrl(current);+ const response = await this.fetchImpl(current, {+ redirect: 'manual',+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),+ headers: { 'User-Agent': 'Mozilla/5.0 TTWChatMediaCache' }+ });+ if ([301, 302, 303, 307, 308].includes(response.status)) {+ const location = response.headers.get('location');+ if (!location || redirect === 3) throw new Error('圖片重新導向過多');+ current = new URL(location, current).href;+ continue;+ }+ if (!response.ok) throw new Error('來源圖片回應 ' + response.status);+ const contentType = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();+ if (!ALLOWED_TYPES.has(contentType)) throw new Error('不支援的圖片類型: ' + (contentType || '未知'));+ const declared = Number(response.headers.get('content-length'));+ if (Number.isFinite(declared) && declared > MAX_BYTES) throw new Error('來源圖片過大');+ const body = Buffer.from(await response.arrayBuffer());+ if (!body.length) throw new Error('來源圖片為空');+ if (body.length > MAX_BYTES) throw new Error('來源圖片過大');+ return { body, contentType, etag: response.headers.get('etag') || hashUrl(current) };+ }+ throw new Error('圖片重新導向過多');+ }++ async ensure(hash, url) {+ if (fs.existsSync(this.filePath(hash))) {+ const row = this.db.prepare('SELECT content_type FROM media WHERE hash=?').get(hash);+ if (row?.content_type) return row.content_type;+ }+ if (this.inflight.has(hash)) return this.inflight.get(hash);+ const task = (async () => {+ try {+ const image = await this.fetchImage(url);+ const temporary = this.filePath(hash) + '.' + process.pid + '.tmp';+ await fsp.writeFile(temporary, image.body);+ await fsp.rename(temporary, this.filePath(hash));+ this.db.prepare(`UPDATE media SET content_type=?,byte_size=?,etag=?,fetched_at=?,+ fail_count=0,last_fail_at=NULL,last_access=? WHERE hash=?`)+ .run(image.contentType, image.body.length, image.etag, this.now(), this.now(), hash);+ return image.contentType;+ } catch (error) {+ this.db.prepare(`UPDATE media SET fail_count=fail_count+1,last_fail_at=? WHERE hash=?`)+ .run(this.now(), hash);+ throw error;+ } finally {+ this.inflight.delete(hash);+ }+ })();+ this.inflight.set(hash, task);+ return task;+ }++ async serve(req, res, hash) {+ if (!['GET', 'HEAD'].includes(req.method)) {+ res.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' });+ res.end('Method Not Allowed');+ return;+ }+ if (!/^[a-f0-9]{64}$/.test(String(hash || ''))) {+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });+ res.end('Not Found');+ return;+ }+ const row = this.db.prepare('SELECT * FROM media WHERE hash=?').get(hash);+ if (!row) {+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });+ res.end('Not Found');+ return;+ }+ try {+ let contentType = row.content_type;+ if (!contentType || !fs.existsSync(this.filePath(hash))) {+ contentType = await this.ensure(hash, row.url);+ }+ const body = await fsp.readFile(this.filePath(hash));+ this.db.prepare('UPDATE media SET last_access=? WHERE hash=?').run(this.now(), hash);+ const etag = '"' + hash + '"';+ if (req.headers['if-none-match'] === etag) {+ res.writeHead(304, { 'Cache-Control': CACHE_CONTROL, ETag: etag });+ res.end();+ return;+ }+ res.writeHead(200, {+ 'Content-Type': contentType || 'application/octet-stream',+ 'Content-Length': body.length,+ 'Cache-Control': CACHE_CONTROL,+ 'ETag': etag,+ 'X-Content-Type-Options': 'nosniff'+ });+ res.end(req.method === 'HEAD' ? undefined : body);+ } catch {+ // 不在公開路由導向原始 URL,避免把私有位址帶給客戶端;失敗就回 404。+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' });+ res.end('Image unavailable');+ }+ }++ close() {+ try { this.db.close(); } catch { /* ignore */ }+ }+}++const defaultMediaCache = new MediaCache();++module.exports = { MediaCache, defaultMediaCache, hashUrl, normalizeImageUrl, isPrivateAddress };diff --git a/ScriptLib/socket/hub.mjs b/ScriptLib/socket/hub.mjs--- a/ScriptLib/socket/hub.mjs+++ b/ScriptLib/socket/hub.mjs@@ -6,14 +6,22 @@ import { MessageDedup } from './dedup.mjs'; * 需要傳輸層、去重、emoji 取代與心跳,此處統一組裝,事件點不必知道有幾個端點。 */ export class SocketHub {- constructor({ endpoints = [], dedup = new MessageDedup(), replaceEmojis = (value) => value, onSend, logger = console } = {}) {+ constructor({ endpoints = [], dedup = new MessageDedup(), replaceEmojis = (value) => value, onSend,+ mediaCache = null, logger = console } = {}) { this.endpoints = endpoints.filter(endpoint => endpoint && endpoint.enabled); this.dedup = dedup; this.replaceEmojis = replaceEmojis; this.onSend = onSend;+ this.mediaCache = mediaCache; this.logger = logger; } + /** 圖片欄位改成本機 media URL;快取不可用時保留原始 URL。 */+ mediaUrl(value) {+ if (!this.mediaCache || !value) return value;+ try { return this.mediaCache.localUrl(value) || value; } catch { return value; }+ }+ /** 主要(第一個)端點,供既有診斷讀取連線狀態。 */ get primary() { return this.endpoints[0] || null; } get enabled() { return this.endpoints.length > 0; }@@ -75,8 +83,8 @@ export class SocketHub { ...(platform ? { platform } : {}), user: String(user), message: this.replaceEmojis(String(message)),- img,- giftImg,+ img: this.mediaUrl(img),+ giftImg: this.mediaUrl(giftImg), isMain: Boolean(isMain), userNum: Number(userNum), userList,@@ -133,10 +141,12 @@ export function splitApis(input) { * @param {(payload: object) => void} [options.onSend] 每送出一則 payload 時的回呼(監控鏡像用)。 * @param {(value: string) => string} [options.replaceEmojis] * @param {import('./dedup.mjs').MessageDedup} [options.dedup]+ * @param {Object|null} [options.mediaCache] 圖片快取(需有 localUrl)。 * @param {Console} [options.logger=console] * @returns {SocketHub} */-export function createHub({ socketApi, apis = [], enabled = true, onConnect, onSend, replaceEmojis, dedup, logger = console } = {}) {+export function createHub({ socketApi, apis = [], enabled = true, onConnect, onSend, replaceEmojis, dedup,+ mediaCache = null, logger = console } = {}) { const specs = []; if (socketApi) specs.push(socketApi); for (const api of splitApis(apis)) specs.push(api);@@ -165,5 +175,5 @@ export function createHub({ socketApi, apis = [], enabled = true, onConnect, onS endpoints.push(new SocketEndpoint({ url, enabled, filter, onConnect, logger })); } }- return new SocketHub({ endpoints, dedup, replaceEmojis, onSend, logger });+ return new SocketHub({ endpoints, dedup, replaceEmojis, onSend, mediaCache, logger }); }diff --git a/Server.js b/Server.js--- a/Server.js+++ b/Server.js@@ -58,6 +58,7 @@ const audience = createAudienceTracker({ timezone: () => planner.settings().timezone }); const serveAudience = createAudienceRoutes(audience);+const serveMedia = require('./MediaRoutes.cjs').createMediaRoutes(); const audiencePrune = setInterval(() => { try { audience.prune(180); } catch (error) { console.error('[Audience] 清理失敗:', error.message); } }, 24 * 3600000);@@ -614,6 +615,7 @@ const server = http.createServer((req, res) => { if (serveMonitorHistory(req, res, isValidToken(parseCookies(req).authToken))) return; if (require('./RestreamRoutes.cjs').serveRestream(req,res,isValidToken(parseCookies(req).authToken))) return; if (serveRuntime(req, res, runtimeState, startRuntime)) return;+ if (serveMedia(req, res)) return; if (require('./WebAssets.cjs').serveWebAsset(req, res)) return; if (require('./EmojiRoutes.cjs').serveEmoji(req, res, isValidToken(parseCookies(req).authToken))) return; if (require('./BrowserRoutes.cjs').serveBrowser(req, res, isValidToken(parseCookies(req).authToken))) return;diff --git a/Test/media_cache.test.cjs b/Test/media_cache.test.cjs--- /dev/null+++ b/Test/media_cache.test.cjs@@ -0,0 +1,121 @@+/** 通用圖片快取:本地 URL、下載、內容型別、304 與私有位址拒絕。 */+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 http = require('node:http');+const { MediaCache, hashUrl, normalizeImageUrl } = require('../ScriptLib/media/cache.cjs');+const { createMediaRoutes } = require('../MediaRoutes.cjs');+const { SocketHub } = require('../ScriptLib/socket/hub.mjs');++const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');++function fakeServe(cache, hash, method = 'GET', headers = {}) {+ return new Promise(resolve => {+ const req = { method, headers };+ const res = {+ headersSent: false,+ writeHead(status, values = {}) { this.status = status; this.headers = values; this.headersSent = true; },+ end(body) { resolve({ status: this.status, headers: this.headers || {}, body }); }+ };+ cache.serve(req, res, hash).catch(error => resolve({ status: 500, error }));+ });+}++test('MediaCache 把外部 URL 換成本地 hash,下載後可再從快取供應', async t => {+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ttw-media-'));+ const server = http.createServer((req, res) => {+ if (req.url === '/img.png') {+ res.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': PNG.length });+ res.end(PNG);+ return;+ }+ if (req.url === '/bad.svg') {+ res.writeHead(200, { 'Content-Type': 'image/svg+xml' });+ res.end('<svg xmlns="http://www.w3.org/2000/svg"/>');+ return;+ }+ res.writeHead(404); res.end();+ });+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));+ const cache = new MediaCache({+ dbFile: path.join(dir, 'media.sqlite'), imageDir: path.join(dir, 'images'),+ baseUrl: 'http://127.0.0.1:3332', allowPrivateHosts: true+ });+ t.after(async () => {+ cache.close();+ await new Promise(resolve => server.close(resolve));+ fs.rmSync(dir, { recursive: true, force: true });+ });+ const inline = 'data:image/png;base64,iVBORw0KGgo=';+ assert.equal(cache.localUrl(inline), inline);++ const source = `http://127.0.0.1:${server.address().port}/img.png`;+ const local = cache.localUrl(source);+ const hash = hashUrl(source);+ assert.equal(local, `http://127.0.0.1:3332/media/${hash}`);+ assert.equal(cache.localUrl(source), local);++ const first = await fakeServe(cache, hash);+ assert.equal(first.status, 200);+ assert.equal(first.headers['Content-Type'], 'image/png');+ assert.deepEqual(Buffer.from(first.body), PNG);+ assert.equal(fs.existsSync(cache.filePath(hash)), true);++ const second = await fakeServe(cache, hash);+ assert.equal(second.status, 200);+ assert.equal(second.headers.ETag, '"' + hash + '"');+ const notModified = await fakeServe(cache, hash, 'GET', { 'if-none-match': second.headers.ETag });+ assert.equal(notModified.status, 304);++ const svgSource = `http://127.0.0.1:${server.address().port}/bad.svg`;+ const svgHash = hashUrl(svgSource);+ cache.localUrl(svgSource);+ assert.equal((await fakeServe(cache, svgHash)).status, 404);+});++test('MediaCache 拒絕私有位址、SVG 與非 HTTP URL', async t => {+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ttw-media-deny-'));+ const cache = new MediaCache({+ dbFile: path.join(dir, 'media.sqlite'), imageDir: path.join(dir, 'images'),+ baseUrl: 'http://127.0.0.1:3332'+ });+ t.after(() => {+ cache.close();+ fs.rmSync(dir, { recursive: true, force: true });+ });+ await assert.rejects(cache.ensure('a'.repeat(64), 'http://127.0.0.1:1234/private.png'));+ assert.equal(normalizeImageUrl('data:image/png;base64,abc'), '');+ assert.equal(normalizeImageUrl('file:///etc/passwd'), '');+});++test('MediaRoutes 只接管 /media/<hash> 並呼叫快取', async () => {+ const calls = [];+ const route = createMediaRoutes({+ serve(req, res, hash) { calls.push(hash); res.writeHead(200); res.end(); return Promise.resolve(); }+ });+ const response = await new Promise(resolve => {+ const req = { url: '/media/' + 'a'.repeat(64), method: 'GET', headers: {} };+ const res = {+ writeHead() {}, end() { resolve(true); }+ };+ assert.equal(route(req, res), true);+ });+ assert.equal(response, true);+ assert.deepEqual(calls, ['a'.repeat(64)]);+ assert.equal(route({ url: '/other', method: 'GET', headers: {} }, {}), false);+});++test('SocketHub 送出前改寫 img 與 giftImg', () => {+ const sent = [];+ const hub = new SocketHub({+ mediaCache: { localUrl: value => 'http://media.test/media/' + hashUrl(value) },+ endpoints: [{ enabled: true, accepts: () => true, send: payload => sent.push(payload) }]+ });+ assert.equal(hub.sendMessage('Alice', 'hello', {+ img: 'https://cdn.example/avatar.png', giftImg: 'https://cdn.example/gift.png', category: 'chat'+ }), true);+ assert.match(sent[0].img, /^http:\/\/media\.test\/media\//);+ assert.match(sent[0].giftImg, /^http:\/\/media\.test\/media\//);+});diff --git a/TikTok.js b/TikTok.js--- a/TikTok.js+++ b/TikTok.js@@ -45,6 +45,7 @@ import Translate from "./TranslateTest.js" import { recordMessageStat, getTopMessages, getAllMessageStatsSorted, processFilter, clearStats, takeThrottleSummaries } from "./MessageFilter.js" import { replaceEmojis, loadEmojiMap, getEmojiMap } from "./EmojiMap.js" import { startKickChat } from './ScriptLib/kick/chat.mjs';+import mediaCacheModule from './ScriptLib/media/cache.cjs'; import console from 'console'; import { AutoClipManager } from './AutoClip.js' @@ -243,6 +244,7 @@ const socketHub = createHub({ enabled: isSocket, replaceEmojis, dedup,+ mediaCache: mediaCacheModule.defaultMediaCache, onSend: (payload) => monitorRelay.forward(payload), onConnect: () => { if (isFirstSocketConnect) {@@ -866,10 +868,22 @@ var CacheUserList = [] // 用於去重的用戶列表 var CacheUserNum = 0 // 合併觀眾數(由 viewerCounts 計算) -function sendAdOverylayMessage(user,text,iconURL,useTTS){+const AD_IMAGE_URL_RE = /https?:\/\/[^\s<>"']+\.(?:png|jpe?g|gif|webp|avif|bmp)(?:\?[^\s<>"']*)?/gi;++function cachedMediaUrl(value) {+ if (!value) return value;+ try {+ return mediaCacheModule.defaultMediaCache.localUrl(value) || value;+ } catch {+ return value;+ }+}++function sendAdOverylayMessage(user, text, iconURL, useTTS) {+ // 只改 overlay 使用的圖片;通知仍保留原始 URL,手機才連得到外部站。+ const localText = String(text || '').replace(AD_IMAGE_URL_RE, url => cachedMediaUrl(url));+ const payload = { type: 'AdOverlay', category: 'ad', user, text: localText, iconURL: cachedMediaUrl(iconURL), useTTS }; - const payload = { type: 'AdOverlay', category: 'ad', user, text, iconURL, useTTS };- console.log('📤 發送 AdOverlay 訊息:', payload); socketHub.send(payload); }diff --git a/todo.md b/todo.md--- a/todo.md+++ b/todo.md@@ -22,6 +22,7 @@ - [ ] 獨立 `/dedup`(或 `/diagnostics/dedup`)診斷頁:最近 test results、其他送入訊息的去重狀態查詢與剩餘冷卻;由 monitor/runtime 提供入口。 - [x] monitor 歷史 SQLite 持久化、最近場次 API 與即時/歷史切換。 - [x] Signer TikTok 導航節流與 WAF 403 冷卻,避免 fetch page 重建風暴。+- [x] 圖片媒體快取 Phase 1:本地 `/media/<hash>`、SocketHub 頭像/禮物圖與 AdOverlay 圖片改寫。 - [ ] 正式環境以實際 child 程序驗證 IPC 結果與倒數(目前 monitor UI 使用假 SSE/fake `/chat`)。 ## 表情映射後續(2026-11-06)