app · b2abbaf

Dev index Recent changes / versions Changelog About TODO

feat(media): data URI 驗證與通知剝離

b2abbaf · 2026-11-10 20:03 · app
Docs/CHAT_API.mdScriptLib/media/inline.cjsScriptLib/notify/format.mjsTest/inline_image.test.cjsTest/notify.test.mjs
commit b2abbaf449a6e34669e086cd1d75bcf64c81b4c9Author: Nuclear0709 <agp05432@gmail.com>Date:   Tue Nov 10 20:03:10 2026 +0800     feat(media): data URI 驗證與通知剝離        - 新增 InlineImage 格式/大小/數量驗證,MediaCache 明確 bypass data URI    - Bark/ntfy 移除內文 data URI 與 inline icon,避免 base64 進第三方 API    - 補 inline/notify 測試與 data URI 文件 diff --git a/Docs/CHAT_API.md b/Docs/CHAT_API.mdindex 5bb8e8a..0dc8392 100644--- a/Docs/CHAT_API.md+++ b/Docs/CHAT_API.md@@ -30,14 +30,32 @@ Userscript → Server.js → TikTok.js → Socket 顯示與自動剪輯統計 | `observedAt` | 毫秒時間戳,可省略 | 瀏覽器觀察時間。目前不作為平台發送時間或熱度計算時間。 | | `receivedAt` | 伺服器產生 | 入口會覆寫成伺服器接收時間,發送端不用設定。 | | `isTest` | 布林,預設 `false` | 只有布林 `true` 標記合成測試資料;仍可顯示、記錄留言,但不參與自動剪輯熱度及觀眾更新。不要傳字串 `"true"`。 |-| `img` | URL 字串,可省略 | 顯示用圖片位址,不是圖片上傳介面。 |-| `giftImg` | URL 字串,可省略 | 相容顯示用禮物圖片,不會因此產生原生平台禮物事件。 |+| `img` | URL 或 data URI 字串,可省略 | 顯示用圖片;HTTP(S) 由 MediaCache 轉本地 URL,data URI 原樣直傳。 |+| `giftImg` | URL 或 data URI 字串,可省略 | 相容顯示用禮物圖片;同上,不會因此產生原生平台禮物事件。 | | `userNum` | 有限且非負的數值,可省略 | 真實總觀眾數;只有知道總數才傳,不要用 `0` 代表未知。 | | `audienceKind` | `total` 或 `top-fans`,可省略 | 總觀眾數建議明確標記 `total`;`top-fans` 會略過觀眾熱度更新。 | | `userList` | 字串陣列,可省略 | 相容欄位,目前不據此更新總觀眾或自動剪輯熱度。 | | `isMain` | 布林,相容欄位 | 目前聊天轉接送往 Socket 時固定使用 `true`,不採用輸入值切換。 | | `heatEligible` | 伺服器衍生 | 由 `isTest` 決定,入口會覆寫;不要自行指定。 | +### data URI 圖片++`img`、`giftImg`、`message` 內嵌圖片、`AdOverlay.iconURL`、贊助橫幅與通知圖片接受:++```text+data:image/png;base64,<payload>+data:image/jpeg;base64,<payload>+data:image/webp;base64,<payload>+data:image/gif;base64,<payload>+```++- 由顯示端本機解碼;MediaCache 不抓取、不快取、不 hash data URI。+- 只接受 `;base64`;拒絕 SVG、PDF、非 base64 與超限內容。+- 單張 encoded 256 KiB、單則合計 768 KiB、最多 20 張。+- 解碼後 4 MP/最大邊 2048 px 由 App 判斷;伺服器不切片、不截斷,也不 fallback 去下載 HTTP。+- 通知(Bark/ntfy)會移除內文 data URI,且不把 data URI 當外部 icon,避免把 base64 塞進第三方 API。+- 歷史持久化不應直接存 base64;需要保留時應 materialize 成 content-addressed 本機 media。+ ### 一般留言  ```jsondiff --git a/ScriptLib/media/inline.cjs b/ScriptLib/media/inline.cjsnew file mode 100644index 0000000..b141c54--- /dev/null+++ b/ScriptLib/media/inline.cjs@@ -0,0 +1,77 @@+/**+ * Inline data URI 圖片驗證。這不是 URL 快取:data URI 由 App 本機解碼,+ * 伺服器只檢查格式與 encoded 大小,不抓取、不切片、不改寫。+ */+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;+const MAX_COUNT = 20;+const DATA_URI_RE = /data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/]+={0,2}/gi;++function isInlineImage(value) {+    return typeof value === 'string' && /^data:image\//i.test(value);+}++/**+ * @param {string} value+ * @returns {{ok: true, mime: string, encodedBytes: number, payload: string}|{ok:false, reason:string}}+ */+function parseDataImage(value) {+    if (typeof value !== 'string') return { ok: false, reason: 'not-string' };+    if (!isInlineImage(value)) return { ok: false, reason: 'not-inline-image' };+    const match = value.match(/^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/]+={0,2})$/i);+    if (!match) return { ok: false, reason: 'unsupported-format' };+    const mime = match[1].toLowerCase();+    if (!ALLOWED_MIME.has(mime)) return { ok: false, reason: 'unsupported-format' };+    const encodedBytes = Buffer.byteLength(value, 'utf8');+    if (!match[2].length || match[2].length % 4 === 1) return { ok: false, reason: 'invalid-base64' };+    return { ok: true, mime, encodedBytes, payload: match[2] };+}++/** 從文字抽出所有 data image URI(包含不支援的 MIME,讓驗證層回報)。 */+function extractDataImages(text) {+    if (typeof text !== 'string') return [];+    return text.match(DATA_URI_RE) || [];+}++/** 移除文字中的 data image URI,主要供 Bark/ntfy 通知使用。 */+function stripInlineImages(text) {+    if (typeof text !== 'string') return text;+    return text.replace(DATA_URI_RE, '').replace(/\s{2,}/g, ' ').trim();+}++/**+ * 驗證單則訊息的 img/giftImg/message 內嵌圖片。+ * @param {{img?:string,giftImg?:string,message?:string}} fields+ * @returns {{ok:boolean, reason?:string, count:number, totalEncodedBytes:number, images:Array}}+ */+function validateInlineImages({ img = '', giftImg = '', message = '' } = {}) {+    const values = [];+    for (const value of [img, giftImg]) {+        if (isInlineImage(value)) values.push(value);+    }+    values.push(...extractDataImages(message));+    let totalEncodedBytes = 0;+    const images = [];+    for (const value of values) {+        const parsed = parseDataImage(value);+        if (!parsed.ok) return { ok: false, reason: parsed.reason, count: values.length, totalEncodedBytes, images };+        if (parsed.encodedBytes > MAX_IMAGE_BYTES) {+            return { ok: false, reason: 'image-too-large', count: values.length, totalEncodedBytes, images };+        }+        totalEncodedBytes += parsed.encodedBytes;+        images.push(parsed);+        if (images.length > MAX_COUNT) {+            return { ok: false, reason: 'too-many-images', count: images.length, totalEncodedBytes, images };+        }+        if (totalEncodedBytes > MAX_TOTAL_BYTES) {+            return { ok: false, reason: 'total-too-large', count: images.length, totalEncodedBytes, images };+        }+    }+    return { ok: true, count: images.length, totalEncodedBytes, images };+}++module.exports = {+    ALLOWED_MIME, MAX_IMAGE_BYTES, MAX_TOTAL_BYTES, MAX_COUNT,+    isInlineImage, parseDataImage, extractDataImages, stripInlineImages, validateInlineImages+};diff --git a/ScriptLib/notify/format.mjs b/ScriptLib/notify/format.mjsindex 884fe90..9660c12 100644--- a/ScriptLib/notify/format.mjs+++ b/ScriptLib/notify/format.mjs@@ -1,5 +1,6 @@ // 推播文字/圖示格式化:Bark 與 ntfy 共用。 // emoji 對照表由呼叫端以 getter 注入,本模組不依賴 EmojiMap.js,方便單元測試。+import inlineMedia from '../media/inline.cjs';  const IMG_URL_RE = /https?:\/\/[^\s]+?\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s]*)?/i; @@ -10,7 +11,9 @@ const IMG_URL_RE = /https?:\/\/[^\s]+?\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s]*)?/i  */ export function stripEmojiUrls(text) {     if (!text || typeof text !== 'string') return text;-    return text.replace(/https?:\/\/[^\s]+(?:png|jpg|jpeg|gif|webp)[^\s]*/gi, '').trim();+    const withoutInline = inlineMedia.stripInlineImages(text);+    const source = withoutInline === text ? text : withoutInline;+    return source.replace(/https?:\/\/[^\s]+(?:png|jpg|jpeg|gif|webp)[^\s]*/gi, '').trim(); }  /**@@ -57,15 +60,17 @@ export function stripEmojiCodes(text, keepCode, map = {}) {  * @param {string} comment 原始訊息  * @param {string} [fallbackIcon] 找不到 emoji/圖片時沿用的圖示  * @param {Object} [map] emoji code → 圖片網址+ * @param {{allowInlineIcon?: boolean}} [options] 外部通道預設不帶 data URI icon  * @returns {{icon: (string|undefined), body: string}}  */-export function buildNotification(comment, fallbackIcon, map = {}) {+export function buildNotification(comment, fallbackIcon, map = {}, { allowInlineIcon = false } = {}) {     const pick = pickEmojiIcon(comment, map);     let icon = pick ? pick.url : fallbackIcon;     if (!pick && typeof comment === 'string') {         const imgMatch = comment.match(IMG_URL_RE);         if (imgMatch) icon = imgMatch[0];     }+    if (!allowInlineIcon && icon && inlineMedia.isInlineImage(icon)) icon = undefined;     const body = stripEmojiCodes(stripEmojiUrls(comment), pick ? pick.code : null, map);     return { icon, body }; }diff --git a/Test/inline_image.test.cjs b/Test/inline_image.test.cjsnew file mode 100644index 0000000..8793673--- /dev/null+++ b/Test/inline_image.test.cjs@@ -0,0 +1,38 @@+/** Inline data URI 圖片的格式、大小、數量與通知剝離。 */+const test = require('node:test');+const assert = require('node:assert/strict');+const {+    parseDataImage, validateInlineImages, stripInlineImages, isInlineImage+} = require('../ScriptLib/media/inline.cjs');++const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');+const DATA = `data:image/png;base64,${PNG.toString('base64')}`;++test('接受 png/jpeg/webp/gif,拒絕 SVG 與非 base64', () => {+    assert.equal(isInlineImage(DATA), true);+    assert.equal(parseDataImage(DATA).ok, true);+    assert.equal(parseDataImage('data:image/svg+xml;base64,AAAA').reason, 'unsupported-format');+    assert.equal(parseDataImage('data:image/png,AAAA').reason, 'unsupported-format');+    assert.equal(parseDataImage('https://x/a.png').reason, 'not-inline-image');+});++test('驗證 img/giftImg/message 的數量與總量', () => {+    const valid = validateInlineImages({ img: DATA, message: `look ${DATA} end` });+    assert.equal(valid.ok, true);+    assert.equal(valid.count, 2);+    assert.equal(valid.totalEncodedBytes, DATA.length * 2);++    const svg = validateInlineImages({ message: 'data:image/svg+xml;base64,AAAA' });+    assert.equal(svg.ok, false);+    assert.equal(svg.reason, 'unsupported-format');++    const tooMany = validateInlineImages({ message: Array.from({ length: 21 }, () => DATA).join(' ') });+    assert.equal(tooMany.ok, false);+    assert.equal(tooMany.reason, 'too-many-images');+});++test('通知文字會移除 data URI,不把 base64 當內文', () => {+    const text = `before ${DATA} after`;+    assert.equal(stripInlineImages(text), 'before after');+    assert.equal(stripInlineImages(text).includes('base64'), false);+});diff --git a/Test/notify.test.mjs b/Test/notify.test.mjsindex cdea999..0c546f6 100644--- a/Test/notify.test.mjs+++ b/Test/notify.test.mjs@@ -46,6 +46,13 @@ test('格式化:emoji 圖示、移除代碼與圖片網址、圖片網址當     const fallback = buildNotification('plain text', 'fallback', {});     assert.equal(fallback.icon, 'fallback');     assert.equal(fallback.body, 'plain text');++    const inline = 'data:image/png;base64,iVBORw0KGgo=';+    const inlineResult = buildNotification(`look ${inline} done`, inline, {});+    assert.equal(inlineResult.icon, undefined);+    assert.equal(inlineResult.body.includes('data:image'), false);+    assert.equal(inlineResult.body.includes('base64'), false);+    assert.equal(buildNotification(`look ${inline}`, inline, {}, { allowInlineIcon: true }).icon, inline); });  test('BarkChannel 依開關與設定啟用,POST 正確 payload', async () => {