Compare commits

...

2 Commits

Author SHA1 Message Date
tursom
4e7036dbff fix: render Steam chat BBCode 2026-08-03 02:22:21 +08:00
tursom
9fb8cb3ee9 fix: render Steam OpenGraph chat previews 2026-08-03 01:02:06 +08:00
3 changed files with 922 additions and 18 deletions

View 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}`);
});

View File

@@ -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 = '图片加载失败';

View File

@@ -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%;
}
}