Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ live2d 渲染页面时将自动读取当前启用主题下的文件。

**需要特别注意的是,一旦用户指定了此 TIPS 文件,运行时会优先将其作为完整 TIPS 文件加载;只有在文件缺失、格式不合法或加载失败时,才会回退到内置默认 TIPS,因此建议自定义时将属性设置完整**

普通提示语支持 `span`、`br`、`b`、`strong`、`em`、`i`、`u`、`s`、`code`、`p` 排版标签,渲染时会过滤其他标签及事件、样式等自定义 HTML 属性。页面标题、选择器提示中的 `{text}` 和一言内容会作为文本显示,不会解释其中的 HTML。AI 回复仍按 Markdown 渲染,原始 HTML 作为文本显示。

### 自定义工具
如果内置工具不满足需求,可以通过 Halo 后台 `插件设置 -> 自定义工具` 声明额外工具按钮。

Expand Down
1 change: 1 addition & 0 deletions packages/live2d/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@lit/context": "^1.1.6",
"@lit/react": "^1.0.8",
"@pixi/sound": "^6.0.1",
"dompurify": "^3.4.17",
"iconify-icon": "^3.0.2",
"lit": "^3.3.3",
"markdown-it": "^14.2.0",
Expand Down
3 changes: 2 additions & 1 deletion packages/live2d/src/components/Live2dTips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
hasMarkdownBlockElements,
renderMarkdown,
} from "@/live2d/helpers/renderMarkdown";
import { renderTipMessage } from "@/live2d/helpers/renderTipMessage";
import { isNotEmpty } from "@/live2d/utils/isNotEmpty";
import { randomSelection } from "@/live2d/utils/randomSelection";
import { consume } from "@lit/context";
Expand Down Expand Up @@ -72,7 +73,7 @@ export class Live2dTips extends UnoLitElement {
render(): TemplateResult {
const renderedMessage = this.isStreamMode
? renderMarkdown(this._message)
: this._message;
: renderTipMessage(this._message);
const isMarkdownBlock =
this.isStreamMode && hasMarkdownBlockElements(renderedMessage);
const classes = {
Expand Down
162 changes: 162 additions & 0 deletions packages/live2d/src/components/__tests__/Live2dTips.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { SendMessageEvent } from "@/live2d/events/send-message";
import {
StreamMessageEvent,
StreamMessageStartEvent,
StreamMessageStopEvent,
} from "@/live2d/events/stream-message";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Live2dTips } from "../Live2dTips";
import "../Live2dTips";

const createTips = async () => {
const tips = document.createElement("live2d-tips") as Live2dTips;
document.body.append(tips);
await tips.updateComplete;
return tips;
};

const getMessage = (tips: Live2dTips) => {
const message = tips.shadowRoot?.querySelector<HTMLElement>("#live2d-tips");
if (!message) {
throw new Error("Live2dTips did not render its message container");
}
return message;
};

const sendMessage = async (tips: Live2dTips, text: string) => {
window.dispatchEvent(
new SendMessageEvent({ text, timeout: 60_000, priority: 1 }),
);
await tips.updateComplete;
return getMessage(tips);
};

const streamMessage = async (tips: Live2dTips, text: string) => {
window.dispatchEvent(new StreamMessageStartEvent({ timeout: 60_000 }));
window.dispatchEvent(new StreamMessageEvent({ text }));
await tips.updateComplete;
return getMessage(tips);
};

describe("Live2dTips message rendering", () => {
afterEach(() => {
document.body.replaceChildren();
vi.useRealTimers();
});

it.each([
'<iframe src="about:blank" srcdoc="<script>alert(1)</script>"></iframe>',
'<img src="invalid" onerror="alert(1)">',
'<svg onload="alert(1)"><a href="javascript:alert(1)">svg</a></svg>',
'<form><input autofocus onfocus="alert(1)"></form>',
"<script>alert(1)</script>",
])("does not mount active HTML in ordinary messages: %s", async (text) => {
const tips = await createTips();
const message = await sendMessage(tips, text);

expect(
message.querySelector("iframe,img,svg,form,input,script,a"),
).toBeNull();
});

it("keeps ordinary tip formatting while removing every HTML attribute", async () => {
const tips = await createTips();
const message = await sendMessage(
tips,
'<span onclick="alert(1)" style="background:url(https://example.com)" data-source="injected" aria-hidden="true">标题</span><br><strong id="injected">重点</strong><em title="injected">内容</em>',
);

expect(message.querySelector("span")?.textContent).toBe("标题");
expect(message.querySelector("br")).not.toBeNull();
expect(message.querySelector("strong")?.textContent).toBe("重点");
expect(message.querySelector("em")?.textContent).toBe("内容");
for (const element of message.querySelectorAll("*")) {
expect(element.attributes.length).toBe(0);
}
});

it("displays escaped HTML as literal text in ordinary messages", async () => {
const tips = await createTips();
const message = await sendMessage(
tips,
"<span>&lt;iframe src=&quot;about:blank&quot;&gt;&lt;/iframe&gt;</span>",
);

expect(message.querySelector("iframe")).toBeNull();
expect(message.textContent).toContain(
'<iframe src="about:blank"></iframe>',
);
expect(message.querySelector("span")).not.toBeNull();
});

it("removes customized element names without losing the tip text", async () => {
const tips = await createTips();
const message = await sendMessage(
tips,
'<span is="issue86-span" onclick="alert(1)">保留文字</span>',
);
const span = message.querySelector("span");

expect(span?.textContent).toBe("保留文字");
expect([null, ""]).toContain(span?.getAttribute("is"));
expect(span?.getAttribute("onclick")).toBeNull();
expect(
message.querySelector("iframe,img,svg,form,input,script"),
).toBeNull();
});

it("displays raw HTML from streamed Markdown as text", async () => {
const tips = await createTips();
const text =
'<iframe src="about:blank"></iframe> <img src=x onerror=alert(1)>';
const message = await streamMessage(tips, text);

expect(message.querySelector("iframe,img")).toBeNull();
expect(message.textContent).toContain(text);
expect(message.classList.contains("live2d-tips-markdown")).toBe(true);
});

it("keeps streamed Markdown code blocks and safe links", async () => {
const tips = await createTips();
const message = await streamMessage(
tips,
'```html\n<iframe src="about:blank"></iframe>\n```\n\n[Halo](https://www.halo.run)',
);
const code = message.querySelector("pre > code.language-html");
const link = message.querySelector<HTMLAnchorElement>("a");

expect(message.querySelector("iframe")).toBeNull();
expect(code?.textContent).toBe('<iframe src="about:blank"></iframe>\n');
expect(link?.getAttribute("href")).toBe("https://www.halo.run");
expect(link?.target).toBe("_blank");
expect(link?.rel).toBe("noopener noreferrer");
});

it("does not create javascript links in streamed Markdown", async () => {
const tips = await createTips();
const message = await streamMessage(tips, "[bad](javascript:alert(1))");

expect(message.querySelector("a")).toBeNull();
expect(message.textContent).toContain("[bad](javascript:alert(1))");
});

it("sanitizes messages when returning from streamed to ordinary tips", async () => {
vi.useFakeTimers();
const tips = await createTips();
await streamMessage(tips, '<img src="invalid" onerror="alert(1)">');
window.dispatchEvent(new StreamMessageStopEvent({ showTimeout: 0 }));
await vi.advanceTimersByTimeAsync(1000);
await tips.updateComplete;

expect(getMessage(tips).querySelector("img")).toBeNull();

const message = await sendMessage(
tips,
'欢迎<span onclick="alert(1)">回来</span><iframe src="about:blank"></iframe>',
);
expect(message.textContent).toContain("欢迎回来");
expect(message.querySelector("iframe")).toBeNull();
expect(message.querySelector("span")?.getAttribute("onclick")).toBeNull();
expect(message.classList.contains("live2d-tips-markdown")).toBe(false);
});
});
112 changes: 112 additions & 0 deletions packages/live2d/src/events/__tests__/tip-events.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { Live2dTips } from "@/live2d/components/Live2dTips";
import { createDefaultLive2dConfig } from "@/live2d/config/default-config";
import type { TipConfig } from "@/live2d/context/config-context";
import { BeforeInitEvent } from "@/live2d/events/before-init";
import { MODEL_READY_EVENT_NAME } from "@/live2d/events/model-ready";
import { loadMergedTips } from "@/live2d/helpers/loadMergedTips";
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from "vitest";
import "@/live2d/components/Live2dTips";

vi.mock("@/live2d/helpers/loadMergedTips", () => ({
loadMergedTips: vi.fn(),
}));

const originalTitle = document.title;
const originalPath = location.href;
const pageTitle = '<iframe src="about:blank" onload="alert(1)"></iframe> & $&';
const elementText = '<img src="probe" onerror="alert(1)"> & $& $$';
const tipConfig: TipConfig = {
click: [
{ selector: "#click-target", text: "<span>{text}</span> / <b>{text}</b>" },
],
mouseover: [
{ selector: "#hover-target", text: "<span>{text}</span> / <b>{text}</b>" },
],
seasons: [],
time: [],
message: { default: [] },
};

const initialize = async (firstOpenSite = false) => {
vi.mocked(loadMergedTips).mockResolvedValue(tipConfig);
const tips = document.createElement("live2d-tips") as Live2dTips;
document.body.append(tips);
await tips.updateComplete;
window.dispatchEvent(
new BeforeInitEvent({
config: {
...createDefaultLive2dConfig(),
firstOpenSite,
openConsole: false,
},
}),
);
await Promise.resolve();
await Promise.resolve();
return tips;
};

describe("tip event text interpolation", () => {
beforeAll(async () => {
document.title = pageTitle;
history.replaceState({}, "", "/posts/issue86");
await import("../tip-events");
});

afterEach(() => {
document.body.replaceChildren();
});

afterAll(() => {
document.title = originalTitle;
history.replaceState({}, "", originalPath);
vi.restoreAllMocks();
});

it("renders the actual page title as text inside the welcome formatting", async () => {
const tips = await initialize(true);
window.dispatchEvent(new CustomEvent(MODEL_READY_EVENT_NAME));
await tips.updateComplete;
const message = tips.shadowRoot?.querySelector("#live2d-tips");

expect(message?.querySelector("iframe")).toBeNull();
expect(message?.querySelector("span")?.textContent).toBe(
`「${pageTitle}」`,
);
expect(message?.textContent).toContain(`欢迎阅读「${pageTitle}」`);
});

it.each([
["click", "click-target"],
["mouseover", "hover-target"],
])(
"renders every {text} placeholder literally on %s",
async (eventName, id) => {
const tips = await initialize();
const target = document.createElement("button");
target.id = id;
target.textContent = elementText;
// jsdom does not compute layout-dependent innerText.
Object.defineProperty(target, "innerText", { value: elementText });
document.body.append(target);
target.dispatchEvent(
new MouseEvent(eventName, { bubbles: true, composed: true }),
);
await tips.updateComplete;
const message = tips.shadowRoot?.querySelector("#live2d-tips");

expect(message?.querySelector("img")).toBeNull();
expect(message?.querySelector("span")?.textContent).toBe(elementText);
expect(message?.querySelector("b")?.textContent).toBe(elementText);
expect(message?.textContent).toContain(`${elementText} / ${elementText}`);
},
);
});
13 changes: 9 additions & 4 deletions packages/live2d/src/events/tip-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from "@/live2d/events/before-init";
import { MODEL_READY_EVENT_NAME } from "@/live2d/events/model-ready";
import { dataWithinRange } from "@/live2d/helpers/dateWithinRange";
import { escapeHtml } from "@/live2d/helpers/escapeHtml";
import { loadMergedTips } from "@/live2d/helpers/loadMergedTips";
import { sendMessage } from "@/live2d/helpers/sendMessage";
import { timeWithinRange } from "@/live2d/helpers/timeWithinRange";
Expand All @@ -38,10 +39,10 @@ const _getWelComeMessage = (times: TipTime[]) => {
}
}

const message = `欢迎阅读<span>「${documentTitle}」</span>`;
const message = `欢迎阅读<span>「${escapeHtml(documentTitle)}」</span>`;
const domain = getReferrerDomain();
return domain
? `Hello!来自 <span>${domain}</span> 的朋友<br>${message}`
? `Hello!来自 <span>${escapeHtml(domain)}</span> 的朋友<br>${message}`
: message;
};

Expand Down Expand Up @@ -110,7 +111,9 @@ const _userClickEvent = (clicks: TipClick[], signal: AbortSignal) => {
if (!message) {
continue;
}
message = message.replace("{text}", target.innerText);
message = message.replace(/\{text\}/g, () =>
escapeHtml(target.innerText),
);
sendMessage(message, 4000, 1);
return;
}
Expand Down Expand Up @@ -139,7 +142,9 @@ const _userMouseoverEvent = (
if (!message) {
continue;
}
message = message.replace("{text}", target.innerText);
message = message.replace(/\{text\}/g, () =>
escapeHtml(target.innerText),
);
sendMessage(message, 4000, 1);
return;
}
Expand Down
17 changes: 17 additions & 0 deletions packages/live2d/src/helpers/__tests__/renderTipMessage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import DOMPurify from "dompurify";
import { expect, it } from "vitest";
import { renderTipMessage } from "../renderTipMessage";

it("falls back to literal text when HTML sanitization is unsupported", () => {
const supported = DOMPurify.isSupported;
const text = '<iframe src="probe" onload="alert(1)"></iframe> & "quote"';
DOMPurify.isSupported = false;
try {
const template = document.createElement("template");
template.innerHTML = renderTipMessage(text);
expect(template.content.querySelector("iframe")).toBeNull();
expect(template.content.textContent).toBe(text);
} finally {
DOMPurify.isSupported = supported;
}
});
1 change: 1 addition & 0 deletions packages/live2d/src/helpers/escapeHtml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { escapeHtml } from "markdown-it/lib/common/utils.mjs";
26 changes: 26 additions & 0 deletions packages/live2d/src/helpers/renderTipMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { escapeHtml } from "@/live2d/helpers/escapeHtml";
import DOMPurify from "dompurify";

// Ordinary tips only need passive text formatting, never embedded content.
export const renderTipMessage = (value: string): string => {
if (!DOMPurify.isSupported) {
return escapeHtml(value);
}
return DOMPurify.sanitize(value, {
ALLOWED_TAGS: [
"span",
"br",
"b",
"strong",
"em",
"i",
"u",
"s",
"code",
"p",
],
ALLOWED_ATTR: [],
ALLOW_ARIA_ATTR: false,
ALLOW_DATA_ATTR: false,
});
};
Loading
Loading