Compare commits
2 Commits
fec21290ec
...
4e7036dbff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e7036dbff | ||
|
|
9fb8cb3ee9 |
402
test/web-message-render.test.ts
Normal file
402
test/web-message-render.test.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
'use strict';
|
||||
|
||||
import type { Context } from 'node:vm';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const vm = require('node:vm');
|
||||
|
||||
type Listener = (event: { currentTarget: FakeElement; target: FakeElement }) => unknown;
|
||||
|
||||
class FakeElement {
|
||||
tagName: string;
|
||||
className = '';
|
||||
children: FakeElement[] = [];
|
||||
dataset: Record<string, string> = {};
|
||||
href = '';
|
||||
src = '';
|
||||
target = '';
|
||||
rel = '';
|
||||
srcset = '';
|
||||
style: Record<string, string> = {};
|
||||
title = '';
|
||||
type = '';
|
||||
alt = '';
|
||||
hidden = false;
|
||||
attributes: Record<string, string> = {};
|
||||
private ownText = '';
|
||||
private listeners = new Map<string, Listener[]>();
|
||||
|
||||
constructor(tagName: string) {
|
||||
this.tagName = tagName.toUpperCase();
|
||||
}
|
||||
|
||||
append(...nodes: FakeElement[]) {
|
||||
this.children.push(...nodes);
|
||||
}
|
||||
|
||||
replaceChildren(...nodes: FakeElement[]) {
|
||||
this.children = [...nodes];
|
||||
this.ownText = '';
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: Listener) {
|
||||
const listeners = this.listeners.get(type) || [];
|
||||
listeners.push(listener);
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
setAttribute(name: string, value: string) {
|
||||
this.attributes[name] = value;
|
||||
}
|
||||
|
||||
async dispatch(type: string) {
|
||||
for (const listener of this.listeners.get(type) || []) {
|
||||
await listener({ currentTarget: this, target: this });
|
||||
}
|
||||
}
|
||||
|
||||
get textContent(): string {
|
||||
return this.ownText + this.children.map((child) => child.textContent).join('');
|
||||
}
|
||||
|
||||
set textContent(value: string) {
|
||||
this.ownText = String(value);
|
||||
this.children = [];
|
||||
}
|
||||
|
||||
findByClass(className: string): FakeElement | null {
|
||||
if (this.className.split(/\s+/).includes(className)) return this;
|
||||
for (const child of this.children) {
|
||||
const found = child.findByClass(className);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
findAllByClass(className: string): FakeElement[] {
|
||||
const matches: FakeElement[] = this.className.split(/\s+/).includes(className) ? [this] : [];
|
||||
return matches.concat(...this.children.map((child) => child.findAllByClass(className)));
|
||||
}
|
||||
|
||||
findByTag(tagName: string): FakeElement | null {
|
||||
if (this.tagName === tagName.toUpperCase()) return this;
|
||||
for (const child of this.children) {
|
||||
const found = child.findByTag(tagName);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
findAllByTag(tagName: string): FakeElement[] {
|
||||
const matches: FakeElement[] = this.tagName === tagName.toUpperCase() ? [this] : [];
|
||||
return matches.concat(...this.children.map((child) => child.findAllByTag(tagName)));
|
||||
}
|
||||
}
|
||||
|
||||
type WebTestApi = {
|
||||
renderMessage: (item: Record<string, unknown>) => FakeElement;
|
||||
};
|
||||
|
||||
function loadWebTestApi(): WebTestApi & {
|
||||
clipboardWrites: string[];
|
||||
lightbox: FakeElement;
|
||||
lightboxImage: FakeElement;
|
||||
} {
|
||||
const appPath = path.resolve(__dirname, '../web/app.js');
|
||||
const appSource = fs.readFileSync(appPath, 'utf8');
|
||||
const bootstrapIndex = appSource.lastIndexOf('bootstrap().catch');
|
||||
assert.notEqual(bootstrapIndex, -1, 'web app bootstrap marker is required by the test harness');
|
||||
|
||||
const appRoot = new FakeElement('div');
|
||||
const lightbox = new FakeElement('div');
|
||||
lightbox.hidden = true;
|
||||
const lightboxImage = new FakeElement('img');
|
||||
const clipboardWrites: string[] = [];
|
||||
const sandbox: Context & { __webTest?: WebTestApi } = {
|
||||
console,
|
||||
document: {
|
||||
querySelector(selector: string) {
|
||||
if (selector === '#app') return appRoot;
|
||||
if (selector === '#lightbox') return lightbox;
|
||||
if (selector === '#lightboxImage') return lightboxImage;
|
||||
return null;
|
||||
},
|
||||
createElement(tagName: string) {
|
||||
return new FakeElement(tagName);
|
||||
},
|
||||
createTextNode(text: string) {
|
||||
const node = new FakeElement('#text');
|
||||
node.textContent = text;
|
||||
return node;
|
||||
},
|
||||
addEventListener() {}
|
||||
},
|
||||
localStorage: { getItem(): null { return null; }, setItem(): void {} },
|
||||
navigator: { clipboard: { async writeText(value: string) { clipboardWrites.push(value); } } },
|
||||
location: { protocol: 'http:', host: 'localhost' },
|
||||
URL,
|
||||
Headers,
|
||||
FormData,
|
||||
Date,
|
||||
Math,
|
||||
JSON,
|
||||
Promise,
|
||||
setTimeout(callback: () => void) { callback(); return 0; },
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval
|
||||
};
|
||||
const testSource = `${appSource.slice(0, bootstrapIndex)}\n;globalThis.__webTest = { renderMessage };`;
|
||||
vm.runInNewContext(testSource, sandbox);
|
||||
assert.ok(sandbox.__webTest);
|
||||
return { ...sandbox.__webTest, clipboardWrites, lightbox, lightboxImage };
|
||||
}
|
||||
|
||||
test('web chat renders Steam OpenGraph messages as preview cards', async () => {
|
||||
const { renderMessage, clipboardWrites } = loadWebTestApi();
|
||||
const url = 'https://b23.tv/example?share_medium=android&share_source=qq&ts=1234567890';
|
||||
const imageUrl = 'https://community.steamstatic.com/chat/image/example/share_image.jpg@1200w_630h';
|
||||
const title = '示例“视频”标题';
|
||||
const description = '视频播放量 12345、弹幕量 67、点赞数 890';
|
||||
const message = `[og url="${url}" img="${imageUrl}" title="${title}" desc="${description}"]${url}[/og]`;
|
||||
|
||||
const rendered = renderMessage({ id: '1', name: 'tursom', type: 'message', message });
|
||||
const card = rendered.findByClass('og-card');
|
||||
|
||||
assert.doesNotMatch(rendered.textContent, /\[\/?og\b/i);
|
||||
assert.ok(card);
|
||||
assert.match(card.textContent, new RegExp(title));
|
||||
assert.match(card.textContent, new RegExp(description));
|
||||
assert.equal(card.findByTag('img')?.src, `/proxy/image?url=${encodeURIComponent(imageUrl)}`);
|
||||
assert.equal(card.findByClass('og-title')?.href, url);
|
||||
assert.equal(card.findByClass('og-domain')?.textContent, 'B23.TV');
|
||||
assert.equal(card.findByClass('og-domain')?.href, url);
|
||||
|
||||
const copyButton = card.findByClass('og-copy-button');
|
||||
assert.ok(copyButton);
|
||||
assert.equal(copyButton.attributes['aria-label'], '复制链接');
|
||||
await copyButton.dispatch('click');
|
||||
assert.deepEqual(clipboardWrites, [url]);
|
||||
|
||||
const imageLink = card.findByClass('og-image-link');
|
||||
assert.ok(imageLink);
|
||||
await card.findByTag('img')?.dispatch('error');
|
||||
assert.equal(imageLink.hidden, true);
|
||||
assert.match(card.textContent, new RegExp(title));
|
||||
});
|
||||
|
||||
test('web chat preserves text around multiple OpenGraph cards', () => {
|
||||
const { renderMessage } = loadWebTestApi();
|
||||
const message = [
|
||||
'前文 ',
|
||||
'[og url="https://first.example/video" title="第一张"]https://first.example/video[/og]',
|
||||
' 中间 ',
|
||||
"[og url='https://second.example/post' desc='第二张描述']https://second.example/post[/og]",
|
||||
' 后文'
|
||||
].join('');
|
||||
|
||||
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
|
||||
const cards = rendered.findAllByClass('og-card');
|
||||
|
||||
assert.equal(cards.length, 2);
|
||||
assert.match(rendered.textContent, /前文 第一张FIRST\.EXAMPLE 中间 第二张描述SECOND\.EXAMPLE 后文/);
|
||||
assert.equal(cards[0].findByClass('og-image-link'), null);
|
||||
assert.equal(cards[1].findByClass('og-title'), null);
|
||||
});
|
||||
|
||||
test('web chat leaves malformed or unsafe OpenGraph markup visible', () => {
|
||||
const { renderMessage } = loadWebTestApi();
|
||||
const messages = [
|
||||
'[og url="https://example.com" title="未闭合"]https://example.com',
|
||||
'[og title="缺少 URL"]fallback[/og]',
|
||||
'[og url="javascript:alert(1)" title="危险协议"]fallback[/og]',
|
||||
'[og url="https://example.com" title=broken]fallback[/og]',
|
||||
'[og url="https://example.com" extra="unknown"]fallback[/og]'
|
||||
];
|
||||
|
||||
for (const message of messages) {
|
||||
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
|
||||
assert.equal(rendered.findByClass('og-card'), null);
|
||||
assert.equal(rendered.textContent, `Alice${message}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('web chat decodes escaped quotes in OpenGraph attributes', () => {
|
||||
const { renderMessage } = loadWebTestApi();
|
||||
const message = '[og url="https://example.com" title="quoted \\"title\\""]https://example.com[/og]';
|
||||
|
||||
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
|
||||
|
||||
assert.match(rendered.findByClass('og-card')?.textContent || '', /quoted "title"/);
|
||||
});
|
||||
|
||||
test('web chat preserves non-duplicate OpenGraph body text', () => {
|
||||
const { renderMessage } = loadWebTestApi();
|
||||
const message = '[og url="https://example.com" title="示例"]额外说明[/og]';
|
||||
|
||||
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
|
||||
|
||||
assert.ok(rendered.findByClass('og-card'));
|
||||
assert.match(rendered.textContent, /示例EXAMPLE\.COM额外说明/);
|
||||
});
|
||||
|
||||
test('web chat handles long unclosed OpenGraph markup without blocking', () => {
|
||||
const { renderMessage } = loadWebTestApi();
|
||||
const message = `[og ${'a'.repeat(26)}`;
|
||||
const startedAt = performance.now();
|
||||
|
||||
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
|
||||
|
||||
assert.ok(performance.now() - startedAt < 250, 'unclosed markup should be handled in linear time');
|
||||
assert.equal(rendered.textContent, `Alice${message}`);
|
||||
});
|
||||
|
||||
test('web chat keeps sticker, emoticon, and plain-link rendering around OpenGraph support', () => {
|
||||
const { renderMessage } = loadWebTestApi();
|
||||
const message = ':wave: https://example.com [sticker type="happy" limit="0"][/sticker]';
|
||||
|
||||
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
|
||||
|
||||
assert.ok(rendered.findByClass('emoticon'));
|
||||
assert.ok(rendered.findByClass('sticker'));
|
||||
assert.equal(rendered.findByTag('a')?.href, 'https://example.com');
|
||||
assert.equal(rendered.findByClass('og-card'), null);
|
||||
});
|
||||
|
||||
test('web chat renders both Steam URL BBCode forms without leaking markup', () => {
|
||||
const { renderMessage } = loadWebTestApi();
|
||||
const firstUrl = 'https://live.example.com/917818?from=chat&room=main';
|
||||
const secondUrl = 'https://video.example.com/watch/1906428959';
|
||||
const message = `前文 [url=${firstUrl}]直播间[/url] 中间 [url]${secondUrl}[/url] 后文`;
|
||||
|
||||
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
|
||||
const links = rendered.findAllByTag('a');
|
||||
|
||||
assert.doesNotMatch(rendered.textContent, /\[\/?url\b/i);
|
||||
assert.match(rendered.textContent, /前文 直播间 中间 https:\/\/video\.example\.com\/watch\/1906428959 后文/);
|
||||
assert.deepEqual(links.map((link) => link.href), [firstUrl, secondUrl]);
|
||||
assert.ok(links.every((link) => link.target === '_blank' && link.rel === 'noopener noreferrer'));
|
||||
});
|
||||
|
||||
test('web chat renders Steam emoticon BBCode and complete sticker types', () => {
|
||||
const { renderMessage } = loadWebTestApi();
|
||||
const message = [
|
||||
'[emoticon]steamfacepalm[/emoticon]',
|
||||
'[sticker type="show love" limit="0"][/sticker]',
|
||||
'[sticker type="伊埃斯跳舞" limit="0"][/sticker]'
|
||||
].join(' ');
|
||||
|
||||
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
|
||||
const emoticons = rendered.findAllByClass('emoticon');
|
||||
const stickers = rendered.findAllByClass('sticker');
|
||||
|
||||
assert.doesNotMatch(rendered.textContent, /\[\/?(?:emoticon|sticker)\b/i);
|
||||
assert.equal(emoticons.length, 1);
|
||||
assert.equal(emoticons[0].src, 'https://community.cloudflare.steamstatic.com/economy/emoticon/steamfacepalm');
|
||||
assert.deepEqual(stickers.map((sticker) => sticker.src), [
|
||||
'/proxy/sticker/show%20love',
|
||||
`/proxy/sticker/${encodeURIComponent('伊埃斯跳舞')}`
|
||||
]);
|
||||
});
|
||||
|
||||
test('web chat renders HAR-style Steam image BBCode with proxying, aspect ratio, and lightbox', async () => {
|
||||
const { renderMessage, lightbox, lightboxImage } = loadWebTestApi();
|
||||
const fullUrl = 'https://images.example.com/ugc/full/image/';
|
||||
const thumbnailUrl = `${fullUrl}?imw=512&&ima=fit&imcolor=%23000000`;
|
||||
const largeUrl = `${fullUrl}?imw=1024&&ima=fit&imcolor=%23000000`;
|
||||
const message = [
|
||||
'图片前文 ',
|
||||
`[img src=${fullUrl} thumbnail_src=${thumbnailUrl} srcset="${largeUrl} 1024w" width=1206 height=1996]`,
|
||||
`[url=${fullUrl}]${fullUrl}[/url][/img]`,
|
||||
' 图片后文'
|
||||
].join('');
|
||||
|
||||
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
|
||||
const shell = rendered.findByClass('bbcode-image-shell');
|
||||
const button = rendered.findByClass('bbcode-image-button');
|
||||
const image = rendered.findByClass('bbcode-image');
|
||||
|
||||
assert.doesNotMatch(rendered.textContent, /\[\/?(?:img|url)\b/i);
|
||||
assert.match(rendered.textContent, /图片前文\s+图片后文/);
|
||||
assert.ok(shell);
|
||||
assert.ok(button);
|
||||
assert.ok(image);
|
||||
assert.equal(image.src, `/proxy/image?url=${encodeURIComponent(thumbnailUrl)}`);
|
||||
assert.equal(image.srcset, `/proxy/image?url=${encodeURIComponent(largeUrl)} 1024w`);
|
||||
assert.equal(button.style.aspectRatio, '1206 / 1996');
|
||||
assert.equal(button.style.width, '253px');
|
||||
|
||||
await button.dispatch('click');
|
||||
assert.equal(lightbox.hidden, false);
|
||||
assert.equal(lightboxImage.src, `/proxy/image?url=${encodeURIComponent(fullUrl)}`);
|
||||
|
||||
await image.dispatch('error');
|
||||
const fallback = shell.findByTag('a');
|
||||
assert.ok(fallback);
|
||||
assert.equal(fallback.href, fullUrl);
|
||||
assert.equal(fallback.textContent, fullUrl);
|
||||
});
|
||||
|
||||
test('web chat falls back from invalid optional image attributes without rejecting the image', () => {
|
||||
const { renderMessage } = loadWebTestApi();
|
||||
const fullUrl = 'https://images.example.com/ugc/full/image/';
|
||||
const message = `[img src=${fullUrl} thumbnail_src=javascript:alert(1) srcset="javascript:alert(1) 2x" width=0 height=bad][url=${fullUrl}]${fullUrl}[/url][/img]`;
|
||||
|
||||
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
|
||||
const button = rendered.findByClass('bbcode-image-button');
|
||||
const image = rendered.findByClass('bbcode-image');
|
||||
|
||||
assert.ok(button);
|
||||
assert.ok(image);
|
||||
assert.equal(image.src, `/proxy/image?url=${encodeURIComponent(fullUrl)}`);
|
||||
assert.equal(image.srcset, '');
|
||||
assert.equal(button.style.aspectRatio, undefined);
|
||||
});
|
||||
|
||||
test('web chat accepts the empty image srcset emitted by Steam', () => {
|
||||
const { renderMessage } = loadWebTestApi();
|
||||
const fullUrl = 'https://images.example.com/ugc/full/image/';
|
||||
const message = `[img src=${fullUrl} thumbnail_src=${fullUrl} srcset="" width=704 height=245][url=${fullUrl}]${fullUrl}[/url][/img]`;
|
||||
|
||||
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
|
||||
const image = rendered.findByClass('bbcode-image');
|
||||
|
||||
assert.ok(image);
|
||||
assert.equal(image.srcset, '');
|
||||
assert.doesNotMatch(rendered.textContent, /\[\/?(?:img|url)\b/i);
|
||||
});
|
||||
|
||||
test('web chat preserves malformed, unsafe, or unsupported Steam BBCode', () => {
|
||||
const { renderMessage } = loadWebTestApi();
|
||||
const messages = [
|
||||
'[url=javascript:alert(1)]危险[/url]',
|
||||
'[url]not a URL[/url]',
|
||||
'[url=https://example.com]未闭合',
|
||||
'[emoticon]bad name[/emoticon]',
|
||||
'[sticker limit="0"][/sticker]',
|
||||
'[sticker type="happy" limit="0"]正文[/sticker]',
|
||||
'[img src=javascript:alert(1)][url=javascript:alert(1)]bad[/url][/img]',
|
||||
'[img src=https://images.example.com/full][url=https://images.example.com/other]other[/url][/img]',
|
||||
'[spoiler]未知标签[/spoiler]'
|
||||
];
|
||||
|
||||
for (const message of messages) {
|
||||
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
|
||||
assert.equal(rendered.textContent, `Alice${message}`);
|
||||
assert.equal(rendered.findByClass('bbcode-image'), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('web chat handles long unclosed supported BBCode without blocking', () => {
|
||||
const { renderMessage } = loadWebTestApi();
|
||||
const message = `[img src=https://example.com/${'a'.repeat(100_000)}`;
|
||||
const startedAt = performance.now();
|
||||
|
||||
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
|
||||
|
||||
assert.ok(performance.now() - startedAt < 250, 'unclosed markup should be handled in linear time');
|
||||
assert.equal(rendered.textContent, `Alice${message}`);
|
||||
});
|
||||
383
web/app.ts
383
web/app.ts
@@ -108,6 +108,26 @@ type MessageItem = ListEntry & {
|
||||
ordinal?: string | number | null;
|
||||
};
|
||||
|
||||
type OpenGraphPreview = {
|
||||
url: string;
|
||||
imageUrl: string;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const supportedBbcodeTags = ['og', 'url', 'img', 'emoticon', 'sticker'] as const;
|
||||
type SupportedBbcodeTag = (typeof supportedBbcodeTags)[number];
|
||||
const supportedBbcodeTagSet = new Set<string>(supportedBbcodeTags);
|
||||
const supportedBbcodePattern = new RegExp(`\\[(${supportedBbcodeTags.join('|')})(?=[\\s=\\]])`, 'gi');
|
||||
|
||||
type SteamImagePreview = {
|
||||
sourceUrl: string;
|
||||
displayUrl: string;
|
||||
sourceSet: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type InventoryItem = Record<string, unknown> & {
|
||||
name?: string;
|
||||
use_count?: number | string;
|
||||
@@ -1363,28 +1383,355 @@ function renderMessage(item: MessageItem) {
|
||||
}
|
||||
|
||||
function appendMessageText(container: HTMLElement, text: string) {
|
||||
const pattern = /\[sticker\s+type=["']?([^"'\]\s]+)["']?[^\]]*\]\s*\[\/sticker\]|:([A-Za-z0-9_+\-.]+):|(https?:\/\/[^\s<>"']+)/gi;
|
||||
const lowerText = text.toLowerCase();
|
||||
let lastIndex = 0;
|
||||
while (lastIndex < text.length) {
|
||||
const block = findSupportedBbcodeStart(text, lastIndex);
|
||||
if (!block) break;
|
||||
const { start, tag } = block;
|
||||
if (start > lastIndex) appendInlineMessageText(container, text.slice(lastIndex, start));
|
||||
|
||||
const openEnd = findBbcodeTagEnd(text, start + tag.length + 1);
|
||||
if (openEnd < 0) {
|
||||
appendInlineMessageText(container, text.slice(start));
|
||||
return;
|
||||
}
|
||||
const closeTag = `[/${tag}]`;
|
||||
const closeStart = lowerText.indexOf(closeTag, openEnd + 1);
|
||||
if (closeStart < 0) {
|
||||
appendInlineMessageText(container, text.slice(start));
|
||||
return;
|
||||
}
|
||||
|
||||
const blockEnd = closeStart + closeTag.length;
|
||||
const attributes = text.slice(start + tag.length + 1, openEnd);
|
||||
const body = text.slice(openEnd + 1, closeStart);
|
||||
if (!appendSupportedBbcode(container, tag, attributes, body)) {
|
||||
appendInlineMessageText(container, text.slice(start, blockEnd));
|
||||
}
|
||||
lastIndex = blockEnd;
|
||||
}
|
||||
if (lastIndex < text.length) appendInlineMessageText(container, text.slice(lastIndex));
|
||||
}
|
||||
|
||||
function findSupportedBbcodeStart(text: string, fromIndex: number): { start: number; tag: SupportedBbcodeTag } | null {
|
||||
supportedBbcodePattern.lastIndex = fromIndex;
|
||||
const match = supportedBbcodePattern.exec(text);
|
||||
const tag = match?.[1].toLowerCase() || '';
|
||||
return match && isSupportedBbcodeTag(tag) ? { start: match.index, tag } : null;
|
||||
}
|
||||
|
||||
function isSupportedBbcodeTag(value: string): value is SupportedBbcodeTag {
|
||||
return supportedBbcodeTagSet.has(value);
|
||||
}
|
||||
|
||||
function findBbcodeTagEnd(text: string, fromIndex: number): number {
|
||||
let quote = '';
|
||||
for (let index = fromIndex; index < text.length; index += 1) {
|
||||
const character = text[index];
|
||||
if (quote) {
|
||||
if (character === '\\' && index + 1 < text.length) index += 1;
|
||||
else if (character === quote) quote = '';
|
||||
} else if (character === '"' || character === "'") {
|
||||
quote = character;
|
||||
} else if (character === ']') {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function appendSupportedBbcode(
|
||||
container: HTMLElement,
|
||||
tag: SupportedBbcodeTag,
|
||||
attributes: string,
|
||||
body: string
|
||||
): boolean {
|
||||
if (tag === 'og') {
|
||||
const preview = parseOpenGraphPreview(attributes);
|
||||
if (!preview) return false;
|
||||
container.append(openGraphNode(preview));
|
||||
if (body.trim() !== preview.url) appendInlineMessageText(container, body);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (tag === 'url') {
|
||||
const url = parseUrlTarget(attributes, body);
|
||||
if (!url) return false;
|
||||
container.append(externalLink('', url, body.trim() ? body : url));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (tag === 'emoticon') {
|
||||
const name = body.trim();
|
||||
if (attributes.trim() || !/^[A-Za-z0-9_+\-.]+$/.test(name)) return false;
|
||||
container.append(emoticonNode(name));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (tag === 'sticker') {
|
||||
const values = parseBbcodeAttributes(attributes, new Set(['type', 'limit']));
|
||||
const type = values?.type?.trim() || '';
|
||||
if (!values || !type || body.trim()) return false;
|
||||
container.append(stickerNode(type));
|
||||
return true;
|
||||
}
|
||||
|
||||
const preview = parseSteamImagePreview(attributes, body);
|
||||
if (!preview) return false;
|
||||
container.append(steamImageNode(preview));
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseUrlTarget(attributes: string, body: string): string {
|
||||
if (!attributes.trim()) return httpUrl(body);
|
||||
const values = parseBbcodeAttributes(`href${attributes}`, new Set(['href']));
|
||||
return values ? httpUrl(values.href) : '';
|
||||
}
|
||||
|
||||
function parseOpenGraphPreview(attributes: string): OpenGraphPreview | null {
|
||||
const values = parseBbcodeAttributes(attributes, new Set(['url', 'img', 'title', 'desc']), true);
|
||||
if (!values) return null;
|
||||
const url = httpUrl(values.url);
|
||||
if (!url) return null;
|
||||
return {
|
||||
url,
|
||||
imageUrl: httpUrl(values.img),
|
||||
title: values.title || '',
|
||||
description: values.desc || ''
|
||||
};
|
||||
}
|
||||
|
||||
function parseBbcodeAttributes(source: string, allowed: Set<string>, requireQuoted = false): Record<string, string> | null {
|
||||
const values: Record<string, string> = {};
|
||||
let index = 0;
|
||||
while (index < source.length) {
|
||||
while (index < source.length && /\s/.test(source[index])) index += 1;
|
||||
if (index >= source.length) break;
|
||||
if (!/[a-z_]/i.test(source[index])) return null;
|
||||
|
||||
const nameStart = index;
|
||||
index += 1;
|
||||
while (index < source.length && /[a-z0-9_-]/i.test(source[index])) index += 1;
|
||||
const name = source.slice(nameStart, index).toLowerCase();
|
||||
if (!allowed.has(name) || Object.hasOwn(values, name)) return null;
|
||||
|
||||
while (index < source.length && /\s/.test(source[index])) index += 1;
|
||||
if (source[index] !== '=') return null;
|
||||
index += 1;
|
||||
while (index < source.length && /\s/.test(source[index])) index += 1;
|
||||
const quote = source[index];
|
||||
if (requireQuoted && quote !== '"' && quote !== "'") return null;
|
||||
|
||||
let value = '';
|
||||
const quoted = quote === '"' || quote === "'";
|
||||
if (quoted) {
|
||||
index += 1;
|
||||
let closed = false;
|
||||
while (index < source.length) {
|
||||
const character = source[index];
|
||||
if (character === quote) {
|
||||
closed = true;
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
if (character === '\\' && index + 1 < source.length && (source[index + 1] === quote || source[index + 1] === '\\')) {
|
||||
value += source[index + 1];
|
||||
index += 2;
|
||||
} else {
|
||||
value += character;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
if (!closed) return null;
|
||||
} else {
|
||||
const valueStart = index;
|
||||
while (index < source.length && !/\s/.test(source[index])) index += 1;
|
||||
value = source.slice(valueStart, index);
|
||||
}
|
||||
if (!value && !quoted) return null;
|
||||
values[name] = value;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function parseSteamImagePreview(attributes: string, body: string): SteamImagePreview | null {
|
||||
const values = parseBbcodeAttributes(attributes, new Set(['src', 'thumbnail_src', 'srcset', 'width', 'height']));
|
||||
if (!values) return null;
|
||||
const sourceUrl = httpUrl(values.src);
|
||||
if (!sourceUrl || !imageBodyMatchesSource(body, sourceUrl)) return null;
|
||||
return {
|
||||
sourceUrl,
|
||||
displayUrl: httpUrl(values.thumbnail_src) || sourceUrl,
|
||||
sourceSet: proxyImageSourceSet(values.srcset),
|
||||
width: positiveInteger(values.width),
|
||||
height: positiveInteger(values.height)
|
||||
};
|
||||
}
|
||||
|
||||
function imageBodyMatchesSource(body: string, sourceUrl: string): boolean {
|
||||
const trimmed = body.trim();
|
||||
if (!/^\[url(?=[=\]])/i.test(trimmed)) return false;
|
||||
const openEnd = findBbcodeTagEnd(trimmed, 4);
|
||||
if (openEnd < 0) return false;
|
||||
const closeStart = trimmed.toLowerCase().indexOf('[/url]', openEnd + 1);
|
||||
if (closeStart < 0 || closeStart + '[/url]'.length !== trimmed.length) return false;
|
||||
const linkBody = trimmed.slice(openEnd + 1, closeStart);
|
||||
const target = parseUrlTarget(trimmed.slice(4, openEnd), linkBody);
|
||||
return target === sourceUrl && linkBody.trim() === sourceUrl;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown): number {
|
||||
const source = typeof value === 'string' ? value.trim() : '';
|
||||
if (!/^[1-9]\d*$/.test(source)) return 0;
|
||||
const parsed = Number(source);
|
||||
return Number.isSafeInteger(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function proxyImageSourceSet(value: unknown): string {
|
||||
const source = typeof value === 'string' ? value.trim() : '';
|
||||
if (!source) return '';
|
||||
const candidates: string[] = [];
|
||||
for (const candidate of source.split(',')) {
|
||||
const parts = candidate.trim().split(/\s+/);
|
||||
if (!parts[0] || parts.length > 2) return '';
|
||||
const url = httpUrl(parts[0]);
|
||||
const descriptor = parts[1] || '';
|
||||
if (!url || (descriptor && !validSourceSetDescriptor(descriptor))) return '';
|
||||
candidates.push(`${proxiedImageUrl(url)}${descriptor ? ` ${descriptor}` : ''}`);
|
||||
}
|
||||
return candidates.join(', ');
|
||||
}
|
||||
|
||||
function validSourceSetDescriptor(value: string): boolean {
|
||||
if (/^[1-9]\d*w$/.test(value)) return true;
|
||||
const match = value.match(/^(\d+(?:\.\d+)?|\.\d+)x$/);
|
||||
return Boolean(match && Number(match[1]) > 0);
|
||||
}
|
||||
|
||||
function httpUrl(value: unknown): string {
|
||||
const source = typeof value === 'string' ? value.trim() : '';
|
||||
if (!source) return '';
|
||||
try {
|
||||
const parsed = new URL(source);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? source : '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function proxiedImageUrl(url: string): string {
|
||||
return `/proxy/image?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
|
||||
function externalLink(className: string, url: string, text = '') {
|
||||
const link = create('a', className, text);
|
||||
link.href = url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
return link;
|
||||
}
|
||||
|
||||
function openGraphNode(preview: OpenGraphPreview) {
|
||||
const card = create('section', 'og-card');
|
||||
const main = create('div', 'og-main');
|
||||
|
||||
if (preview.imageUrl) {
|
||||
const imageLink = externalLink('og-image-link', preview.url);
|
||||
const image = document.createElement('img');
|
||||
image.className = 'og-image';
|
||||
image.src = proxiedImageUrl(preview.imageUrl);
|
||||
image.alt = preview.title || '链接预览';
|
||||
image.loading = 'lazy';
|
||||
image.addEventListener('error', () => {
|
||||
imageLink.hidden = true;
|
||||
});
|
||||
imageLink.append(image);
|
||||
main.append(imageLink);
|
||||
}
|
||||
|
||||
const body = create('div', 'og-body');
|
||||
if (preview.title) body.append(externalLink('og-title', preview.url, preview.title));
|
||||
if (preview.description) body.append(create('p', 'og-description', preview.description));
|
||||
main.append(body);
|
||||
|
||||
const footer = create('div', 'og-footer');
|
||||
const domain = externalLink('og-domain', preview.url, new URL(preview.url).hostname.replace(/^www\./i, '').toUpperCase());
|
||||
domain.title = preview.url;
|
||||
const copyButton = create('button', 'og-copy-button');
|
||||
copyButton.type = 'button';
|
||||
copyButton.title = '复制链接';
|
||||
copyButton.setAttribute('aria-label', '复制链接');
|
||||
copyButton.append(create('span', 'og-copy-icon'));
|
||||
copyButton.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(preview.url);
|
||||
copyButton.title = '已复制';
|
||||
setTimeout(() => {
|
||||
copyButton.title = '复制链接';
|
||||
}, 1500);
|
||||
} catch (_) {
|
||||
setFeedback('复制链接失败', 'error');
|
||||
}
|
||||
});
|
||||
footer.append(domain, copyButton);
|
||||
card.append(main, footer);
|
||||
return card;
|
||||
}
|
||||
|
||||
function emoticonNode(name: string) {
|
||||
const image = document.createElement('img');
|
||||
image.className = 'emoticon';
|
||||
image.src = `https://community.cloudflare.steamstatic.com/economy/emoticon/${encodeURIComponent(name)}`;
|
||||
image.alt = `:${name}:`;
|
||||
return image;
|
||||
}
|
||||
|
||||
function stickerNode(type: string) {
|
||||
const image = document.createElement('img');
|
||||
image.className = 'sticker';
|
||||
image.src = `/proxy/sticker/${encodeURIComponent(type)}`;
|
||||
image.alt = type;
|
||||
return image;
|
||||
}
|
||||
|
||||
function steamImageNode(preview: SteamImagePreview) {
|
||||
const shell = create('span', 'bbcode-image-shell');
|
||||
const button = create('button', 'image-button bbcode-image-button');
|
||||
button.type = 'button';
|
||||
button.title = '查看大图';
|
||||
button.setAttribute('aria-label', '查看大图');
|
||||
if (preview.width && preview.height) {
|
||||
button.style.aspectRatio = `${preview.width} / ${preview.height}`;
|
||||
button.style.width = `${Math.max(1, Math.min(420, Math.floor((420 * preview.width) / preview.height)))}px`;
|
||||
}
|
||||
|
||||
const image = document.createElement('img');
|
||||
image.className = 'bbcode-image';
|
||||
image.src = proxiedImageUrl(preview.displayUrl);
|
||||
if (preview.sourceSet) image.srcset = preview.sourceSet;
|
||||
image.alt = '图片';
|
||||
image.loading = 'lazy';
|
||||
image.addEventListener('error', () => {
|
||||
const fallback = externalLink('bbcode-image-fallback', preview.sourceUrl, preview.sourceUrl);
|
||||
fallback.title = '图片加载失败';
|
||||
shell.replaceChildren(fallback);
|
||||
});
|
||||
button.addEventListener('click', () => openLightbox(proxiedImageUrl(preview.sourceUrl)));
|
||||
button.append(image);
|
||||
shell.append(button);
|
||||
return shell;
|
||||
}
|
||||
|
||||
function appendInlineMessageText(container: HTMLElement, text: string) {
|
||||
const pattern = /:([A-Za-z0-9_+\-.]+):|(https?:\/\/[^\s<>"'\[\]]+)/gi;
|
||||
let lastIndex = 0;
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
if (match.index > lastIndex) container.append(document.createTextNode(text.slice(lastIndex, match.index)));
|
||||
if (match[1]) {
|
||||
const image = document.createElement('img');
|
||||
image.className = 'sticker';
|
||||
image.src = `/proxy/sticker/${encodeURIComponent(match[1])}`;
|
||||
image.alt = match[1];
|
||||
container.append(image);
|
||||
container.append(emoticonNode(match[1]));
|
||||
} else if (match[2]) {
|
||||
const image = document.createElement('img');
|
||||
image.className = 'emoticon';
|
||||
image.src = `https://community.cloudflare.steamstatic.com/economy/emoticon/${encodeURIComponent(match[2])}`;
|
||||
image.alt = `:${match[2]}:`;
|
||||
container.append(image);
|
||||
} else if (match[3]) {
|
||||
const link = create('a', '', match[3]);
|
||||
link.href = match[3];
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
container.append(link);
|
||||
container.append(externalLink('', match[2], match[2]));
|
||||
}
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
@@ -1395,7 +1742,7 @@ function imageNode(sourceUrl: string) {
|
||||
const shell = create('button', 'image-button', '加载图片');
|
||||
shell.type = 'button';
|
||||
const image = document.createElement('img');
|
||||
image.src = `/proxy/image?url=${encodeURIComponent(sourceUrl)}`;
|
||||
image.src = proxiedImageUrl(sourceUrl);
|
||||
image.alt = '图片';
|
||||
image.onload = () => shell.replaceChildren(image);
|
||||
image.onerror = () => shell.textContent = '图片加载失败';
|
||||
|
||||
155
web/style.css
155
web/style.css
@@ -707,6 +707,123 @@ nav button.is-active {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.og-card {
|
||||
width: min(640px, 100%);
|
||||
max-width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-muted);
|
||||
margin: 4px 0;
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.og-main {
|
||||
display: flow-root;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.og-image-link {
|
||||
display: block;
|
||||
float: left;
|
||||
width: min(256px, 42%);
|
||||
aspect-ratio: 16 / 9;
|
||||
margin: 0 12px 8px 0;
|
||||
border-radius: 6px;
|
||||
background: var(--soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.og-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.og-title {
|
||||
display: block;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.og-title:hover {
|
||||
color: var(--brand);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.og-description {
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.og-footer {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--soft);
|
||||
padding: 4px 6px 4px 8px;
|
||||
}
|
||||
|
||||
.og-domain {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
color: var(--brand);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0;
|
||||
text-decoration: none;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.og-copy-button {
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 28px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.og-copy-button:hover,
|
||||
.og-copy-button:focus-visible {
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.og-copy-icon {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 2px;
|
||||
transform: translate(2px, 2px);
|
||||
}
|
||||
|
||||
.og-copy-icon::before {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
left: -5px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 2px;
|
||||
background: var(--soft);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.emoticon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
@@ -735,6 +852,34 @@ nav button.is-active {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.bbcode-image-shell {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.bbcode-image-button {
|
||||
width: 420px;
|
||||
max-width: 100%;
|
||||
max-height: 420px;
|
||||
overflow: hidden;
|
||||
cursor: zoom-in;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bbcode-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 420px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.bbcode-image-fallback {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.composer {
|
||||
position: relative;
|
||||
display: grid;
|
||||
@@ -992,4 +1137,14 @@ nav button.is-active {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.og-image-link {
|
||||
float: none;
|
||||
width: 100%;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.bbcode-image-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user