app · b906ee4

Dev index Recent changes / versions Changelog About TODO

feat(media): 圖片快取與本地 URL

b906ee4 · 2026-11-10 20:03 · app
.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.exampleindex d963236..7b15628 100644--- 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/.gitignoreindex 8aa53e5..f3dac75 100644--- 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.mdindex 5b50978..5f80b09 100644--- 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.mdnew file mode 100644index 0000000..b73202f--- /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.mdindex be1dcd1..a6f2fe6 100644--- 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.mdindex dd78819..ade8b03 100644--- 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.cjsnew file mode 100644index 0000000..4619b36--- /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.cjsnew file mode 100644index 0000000..1a38b0e--- /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.mjsindex db3070d..bcf9e15 100644--- 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.jsindex 7519e31..22adfa4 100644--- 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.cjsnew file mode 100644index 0000000..f178da1--- /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.jsindex d7e0eff..e08d4a6 100644--- 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.mdindex 82f85c7..29057ff 100644--- 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)