From 477e4809bac3da3584dfec17be97c991fec78ba3 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Mon, 29 Jun 2026 03:14:39 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=B8=B8=E4=B9=90?= =?UTF-8?q?=E5=9C=BA=E4=B8=80=E9=94=AE=E6=B8=85=E9=99=A4=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/playground-input.tsx | 35 ++++++++++ web/default/src/features/playground/index.tsx | 65 +++++++++++-------- web/default/src/i18n/locales/en.json | 2 + web/default/src/i18n/locales/fr.json | 2 + web/default/src/i18n/locales/ja.json | 2 + web/default/src/i18n/locales/ru.json | 2 + web/default/src/i18n/locales/vi.json | 2 + web/default/src/i18n/locales/zh.json | 2 + 8 files changed, 86 insertions(+), 26 deletions(-) diff --git a/web/default/src/features/playground/components/playground-input.tsx b/web/default/src/features/playground/components/playground-input.tsx index 7989323..55c0334 100644 --- a/web/default/src/features/playground/components/playground-input.tsx +++ b/web/default/src/features/playground/components/playground-input.tsx @@ -26,6 +26,7 @@ import { GlobeIcon, SendIcon, SquareIcon, + Trash2Icon, BarChartIcon, BoxIcon, NotepadTextIcon, @@ -48,6 +49,12 @@ import { PromptInputTools, type PromptInputMessage, } from '@/components/ai-elements/prompt-input' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' import { Suggestion, Suggestions } from '@/components/ai-elements/suggestion' import { ModelGroupSelector } from '@/components/model-group-selector' import type { ModelOption, GroupOption } from '../types' @@ -64,6 +71,8 @@ interface PlaygroundInputProps { groups: GroupOption[] groupValue: string onGroupChange: (value: string) => void + hasMessages?: boolean + onClearHistory?: () => void } const suggestions = [ @@ -87,6 +96,8 @@ export function PlaygroundInput({ groups, groupValue, onGroupChange, + hasMessages = false, + onClearHistory, }: PlaygroundInputProps) { const { t } = useTranslation() const [text, setText] = useState('') @@ -94,6 +105,7 @@ export function PlaygroundInput({ const isModelSelectDisabled = disabled || isModelLoading || models.length === 0 const isGroupSelectDisabled = disabled || groups.length === 0 + const isClearHistoryDisabled = disabled || isGenerating || !hasMessages const handleSubmit = (message: PromptInputMessage) => { if (!message.text?.trim() || disabled) return @@ -180,6 +192,29 @@ export function PlaygroundInput({ {t('Search')} {t('Search')} + + + + }> + + + {t('Clear')} + + {t('Clear chat history')} + + + + +

{t('Clear chat history')}

+
+
+
diff --git a/web/default/src/features/playground/index.tsx b/web/default/src/features/playground/index.tsx index d25e685..d8445cf 100644 --- a/web/default/src/features/playground/index.tsx +++ b/web/default/src/features/playground/index.tsx @@ -39,6 +39,7 @@ export function Playground() { setModels, setGroups, updateConfig, + clearMessages, } = usePlaygroundState() const { sendChat, stopGeneration, isGenerating } = useChatHandler({ @@ -53,39 +54,41 @@ export function Playground() { ) // Load models - const { data: modelsData, isLoading: isLoadingModels } = useQuery({ + const { + data: modelsData, + error: modelsError, + isLoading: isLoadingModels, + } = useQuery({ queryKey: ['playground-models'], - queryFn: async () => { - try { - return await getUserModels() - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : t('Failed to load playground models') - ) - return [] - } - }, + queryFn: getUserModels, }) // Load groups - const { data: groupsData } = useQuery({ + const { data: groupsData, error: groupsError } = useQuery({ queryKey: ['playground-groups'], - queryFn: async () => { - try { - return await getUserGroups() - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : t('Failed to load playground groups') - ) - return [] - } - }, + queryFn: getUserGroups, }) + useEffect(() => { + if (!modelsError) return + + toast.error( + modelsError instanceof Error + ? modelsError.message + : t('Failed to load playground models') + ) + }, [modelsError, t]) + + useEffect(() => { + if (!groupsError) return + + toast.error( + groupsError instanceof Error + ? groupsError.message + : t('Failed to load playground groups') + ) + }, [groupsError, t]) + // Update models when data changes useEffect(() => { if (!modelsData) return @@ -188,6 +191,14 @@ export function Playground() { updateMessages(newMessages) } + const handleClearHistory = useCallback(() => { + if (isGenerating || messages.length === 0) return + + clearMessages() + setEditingMessageKey(null) + toast.success(t('Chat history cleared')) + }, [clearMessages, isGenerating, messages.length, t]) + return (
{/* Full-width scroll container: scrolling works even over side whitespace */} @@ -216,7 +227,9 @@ export function Playground() { isModelLoading={isLoadingModels} modelValue={config.model} models={models} + hasMessages={messages.length > 0} onGroupChange={(value) => updateConfig('group', value)} + onClearHistory={handleClearHistory} onModelChange={(value) => updateConfig('model', value)} onStop={stopGeneration} onSubmit={handleSendMessage} diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 08856f5..7f2d304 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -795,6 +795,7 @@ "Chat Client Name": "Chat Client Name", "Chat client name is required": "Chat client name is required", "Chat configuration JSON": "Chat configuration JSON", + "Chat history cleared": "Chat history cleared", "Chat interfaces": "Chat interfaces", "Chat preset not found": "Chat preset not found", "Chat Presets": "Chat Presets", @@ -851,6 +852,7 @@ "Clear All Cache": "Clear All Cache", "Clear all filters": "Clear all filters", "Clear cache for this rule": "Clear cache for this rule", + "Clear chat history": "Clear chat history", "Clear filters": "Clear filters", "Clear Mapping": "Clear Mapping", "Clear mode flags in prompts": "Clear mode flags in prompts", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index aa80441..7733c1d 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -795,6 +795,7 @@ "Chat Client Name": "Nom du client de chat", "Chat client name is required": "Le nom du client de chat est requis", "Chat configuration JSON": "Configuration du chat JSON", + "Chat history cleared": "Historique du chat effacé", "Chat interfaces": "Interfaces de chat", "Chat preset not found": "Préréglage de chat introuvable", "Chat Presets": "Préréglages de chat", @@ -851,6 +852,7 @@ "Clear All Cache": "Vider tout le cache", "Clear all filters": "Effacer tous les filtres", "Clear cache for this rule": "Vider le cache de cette règle", + "Clear chat history": "Effacer l'historique du chat", "Clear filters": "Effacer les filtres", "Clear Mapping": "Effacer le mappage", "Clear mode flags in prompts": "Effacer les indicateurs de mode dans les prompts", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 910fce4..f1e759a 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -795,6 +795,7 @@ "Chat Client Name": "チャットクライアント名", "Chat client name is required": "チャットクライアント名は必須です", "Chat configuration JSON": "チャット設定JSON", + "Chat history cleared": "チャット履歴をクリアしました", "Chat interfaces": "チャットインターフェース", "Chat preset not found": "チャットプリセットが見つかりません", "Chat Presets": "チャットプリセット", @@ -851,6 +852,7 @@ "Clear All Cache": "全キャッシュをクリア", "Clear all filters": "すべてのフィルターをクリア", "Clear cache for this rule": "このルールのキャッシュをクリア", + "Clear chat history": "チャット履歴をクリア", "Clear filters": "フィルターをクリア", "Clear Mapping": "マッピングをクリア", "Clear mode flags in prompts": "プロンプト内のモードフラグをクリア", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 5effb59..5135a0e 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -795,6 +795,7 @@ "Chat Client Name": "Имя чат-клиента", "Chat client name is required": "Название чат-клиента обязательно", "Chat configuration JSON": "JSON конфигурации чата", + "Chat history cleared": "История чата очищена", "Chat interfaces": "Интерфейсы чата", "Chat preset not found": "Предустановка чата не найдена", "Chat Presets": "Предустановки чата", @@ -851,6 +852,7 @@ "Clear All Cache": "Очистить весь кэш", "Clear all filters": "Очистить все фильтры", "Clear cache for this rule": "Очистить кэш этого правила", + "Clear chat history": "Очистить историю чата", "Clear filters": "Очистить фильтры", "Clear Mapping": "Очистить сопоставление", "Clear mode flags in prompts": "Очистить флаги режимов в промптах", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 75b0539..31827f0 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -795,6 +795,7 @@ "Chat Client Name": "Tên ứng dụng khách trò chuyện", "Chat client name is required": "Tên ứng dụng chat là bắt buộc", "Chat configuration JSON": "Cấu hình trò chuyện JSON", + "Chat history cleared": "Đã xóa lịch sử trò chuyện", "Chat interfaces": "Giao diện chat", "Chat preset not found": "Thiết lập sẵn trò chuyện không tìm thấy", "Chat Presets": "Cài đặt sẵn trò chuyện", @@ -851,6 +852,7 @@ "Clear All Cache": "Xóa toàn bộ bộ nhớ đệm", "Clear all filters": "Xóa tất cả bộ lọc", "Clear cache for this rule": "Xóa bộ nhớ đệm của quy tắc này", + "Clear chat history": "Xóa lịch sử trò chuyện", "Clear filters": "Clear filter", "Clear Mapping": "Xóa Ánh xạ", "Clear mode flags in prompts": "Xóa các cờ chế độ trong lời nhắc", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 7efcb97..7aca7bd 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -795,6 +795,7 @@ "Chat Client Name": "聊天客户端名称", "Chat client name is required": "聊天客户端名称为必填项", "Chat configuration JSON": "聊天配置 JSON", + "Chat history cleared": "聊天记录已清空", "Chat interfaces": "Chat 界面", "Chat preset not found": "未找到聊天预设", "Chat Presets": "聊天预设", @@ -851,6 +852,7 @@ "Clear All Cache": "清空全部缓存", "Clear all filters": "清除所有筛选", "Clear cache for this rule": "清空该规则缓存", + "Clear chat history": "清空聊天记录", "Clear filters": "清除筛选器", "Clear Mapping": "清除映射", "Clear mode flags in prompts": "在提示中清除模式标志", From 3ed3a264c8cc33807b1382e6a803dc401aa78519 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Mon, 29 Jun 2026 15:03:46 +0800 Subject: [PATCH 2/5] fix some bugs --- .agents/upstream-sync/new-api-sync.md | 53 ++++++++++++++++ .gitignore | 4 +- .../src/components/html-content.test.ts | 40 +++++++++++++ web/default/src/components/html-content.tsx | 43 +++++++++++++ .../src/components/notification-popover.tsx | 8 +-- web/default/src/components/rich-content.tsx | 36 +++++++++++ web/default/src/features/about/index.tsx | 39 ++++-------- .../overview/announcement-detail-dialog.tsx | 11 ++-- web/default/src/features/home/index.tsx | 16 +++-- .../src/features/legal/legal-document.tsx | 38 ++++-------- .../components/playground-input.tsx | 4 +- web/default/src/features/playground/index.tsx | 17 ++---- web/default/src/lib/html-sanitizer.ts | 60 +++++++++++++++++++ 13 files changed, 283 insertions(+), 86 deletions(-) create mode 100644 .agents/upstream-sync/new-api-sync.md create mode 100644 web/default/src/components/html-content.test.ts create mode 100644 web/default/src/components/html-content.tsx create mode 100644 web/default/src/components/rich-content.tsx create mode 100644 web/default/src/lib/html-sanitizer.ts diff --git a/.agents/upstream-sync/new-api-sync.md b/.agents/upstream-sync/new-api-sync.md new file mode 100644 index 0000000..8610478 --- /dev/null +++ b/.agents/upstream-sync/new-api-sync.md @@ -0,0 +1,53 @@ +# New API Upstream Sync Log + +This file tracks selective synchronization from upstream New API into MAX-API. It is a durable project workflow record, not a temporary investigation note. + +## Current Baseline + +- Upstream checkout: `new-api/new-api` +- Upstream remote: `https://github.com/QuantumNous/new-api.git` +- Last reviewed upstream commit: `25f998595d2da4ac9c749f3eae8fffcf9047bc3e` +- Last reviewed upstream tag: `v1.0.0-rc.15` +- MAX-API branch reviewed: `cscitech` +- MAX-API commit reviewed: `477e4809bac3da3584dfec17be97c991fec78ba3` +- Last review date: `2026-06-29` + +## Sync Rules + +- Do not wholesale copy upstream files. +- Preserve MAX-API branding, package/import paths, billing, quota, relay retry, security, and UI customizations. +- Prioritize security fixes, billing/log correctness, relay compatibility, database migrations, and operational reliability. +- Keep scratch notes and generated diffs under `.tmp/`; keep this durable sync log in `.agents/upstream-sync/`. +- For frontend user-facing text, update all supported i18n locales when new strings are introduced. + +## Status Values + +- `pending`: not yet analyzed +- `accepted`: selected for porting +- `ported`: ported into MAX-API +- `skipped`: intentionally not merged +- `superseded`: MAX-API already has equivalent or stronger behavior +- `blocked`: needs a decision or prerequisite + +## Review Queue + +| Upstream commit/range | Category | Summary | Status | MAX action | Verification | Notes | +| --- | --- | --- | --- | --- | --- | --- | +| `0b48ad86`, `626dadb5` | security, frontend | Custom HTML/Markdown rendering now uses shared `RichContent`/`HtmlContent`; raw HTML is sanitized before rendering on Home, About, Legal, notification popover, and announcement detail content. | ported | Added MAX-API `HtmlContent` and `RichContent`, replaced unsafe `dangerouslySetInnerHTML` paths in public content pages, sandboxed public content iframes, and reused existing `getRenderableContentKind`. | `bun run typecheck`; targeted Node tests for `html-content`, `markdown`, and `renderable-content`. | Highest-priority fix from the 2026-06-29 upstream review. Preserve MAX-API public page layout and branding while porting. | +| `3a506f50`, `2d5a0416` | relay, compatibility | Harden Chat-to-Responses conversion and add Responses-to-Chat/Gemini Responses support. | pending | Evaluate after security rendering fix. | - | Likely conflicts with MAX-API empty completion retry and billing/log behavior. | +| `df44a75d` | bugfix, logs | Adapt ClickHouse log LIKE filters. | pending | Evaluate if ClickHouse log DB is enabled or supported in MAX-API deployments. | - | Current MAX-API still has generic `LIKE ? ESCAPE '!'` in `model/log.go`. | +| `d10fc762` | bugfix, tasks | Attribute async task usage log to the initiating node. | pending | Port narrowly through task private data and task billing log attribution. | - | Important for multi-node deployments. | +| `4aee5f7d` | permissions, admin | Better admin permissions/RBAC for channels and users. | pending | Needs design review before porting. | - | Large change touching backend authz, routes, models, and frontend permissions. | +| `966af88e` | frontend, playground | Improve Playground chat experience and Markdown rendering. | pending | Evaluate after preserving MAX-API one-click clear behavior from `477e4809`. | - | Large frontend refactor. | +| `25f99859`, `1d166532` | frontend, channels | Refine channel management UI and channel drawer layout. | pending | Evaluate after higher-priority fixes. | - | Large channel drawer diff; likely conflicts with MAX-API custom channel UX. | + +## Incremental Sync Procedure + +1. Fetch upstream in `new-api/new-api`. +2. Read `Last reviewed upstream commit` from this file. +3. Compare only `last_reviewed..origin/main`. +4. Classify each upstream commit into security, bugfix, relay, billing, database, frontend, ops, docs, or tooling. +5. Update the Review Queue with status and rationale before porting. +6. Port only `accepted` items, preserving MAX-API behavior. +7. Record verification commands and any skipped/conflicting areas. +8. Advance `Last reviewed upstream commit` only after the range has been analyzed. diff --git a/.gitignore b/.gitignore index d4bb9a3..a7adb6c 100644 --- a/.gitignore +++ b/.gitignore @@ -24,7 +24,9 @@ tiktoken_cache plans .claude .cursor -.agents +.agents/* +!.agents/upstream-sync/ +!.agents/upstream-sync/** .codex .tmp diff --git a/web/default/src/components/html-content.test.ts b/web/default/src/components/html-content.test.ts new file mode 100644 index 0000000..041b4c1 --- /dev/null +++ b/web/default/src/components/html-content.test.ts @@ -0,0 +1,40 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' +import { JSDOM } from 'jsdom' +import { sanitizeHtmlContent } from '@/lib/html-sanitizer' + +const dom = new JSDOM('') +Object.defineProperty(globalThis, 'window', { + configurable: true, + value: dom.window, +}) + +describe('sanitizeHtmlContentForTest', () => { + test('removes executable attributes from custom HTML content', () => { + const html = sanitizeHtmlContent( + '
' + ) + + assert.doesNotMatch(html, /onerror/i) + assert.doesNotMatch(html, /
' + '
safe
' ) + assert.match(html, /safe/) + assert.match(html, / { + const originalIsSupported = Object.getOwnPropertyDescriptor( + DOMPurify, + 'isSupported' + ) + const originalSanitize = Object.getOwnPropertyDescriptor( + DOMPurify, + 'sanitize' + ) + + Object.defineProperty(DOMPurify, 'isSupported', { + configurable: true, + value: true, + }) + Object.defineProperty(DOMPurify, 'sanitize', { + configurable: true, + value: () => { + throw new Error('sanitize failed') + }, + }) + + try { + assert.equal(sanitizeHtmlContent('

safe

'), '') + } finally { + if (originalSanitize) { + Object.defineProperty(DOMPurify, 'sanitize', originalSanitize) + } else { + delete (DOMPurify as Partial).sanitize + } + + if (originalIsSupported) { + Object.defineProperty(DOMPurify, 'isSupported', originalIsSupported) + } else { + delete (DOMPurify as Partial).isSupported + } + } + }) + + test('fails closed when sanitizer factory setup throws', () => { + const windowDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'window' + ) + + Object.defineProperty(globalThis, 'window', { + configurable: true, + get() { + throw new Error('window unavailable') + }, + }) + + try { + assert.equal(sanitizeHtmlContent('

safe

'), '') + } finally { + if (windowDescriptor) { + Object.defineProperty(globalThis, 'window', windowDescriptor) + } else { + delete (globalThis as Partial).window + } + } + }) }) diff --git a/web/default/src/features/about/index.tsx b/web/default/src/features/about/index.tsx index 8a04ec6..b91feaf 100644 --- a/web/default/src/features/about/index.tsx +++ b/web/default/src/features/about/index.tsx @@ -151,7 +151,7 @@ export function About() { src={rawContent} className='h-[calc(100vh-3.5rem)] w-full border-0' title={t('About')} - sandbox='allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts' + sandbox='allow-forms allow-popups allow-scripts' /> ) diff --git a/web/default/src/features/dashboard/components/overview/announcement-detail-dialog.tsx b/web/default/src/features/dashboard/components/overview/announcement-detail-dialog.tsx index ece2a09..9364f3a 100644 --- a/web/default/src/features/dashboard/components/overview/announcement-detail-dialog.tsx +++ b/web/default/src/features/dashboard/components/overview/announcement-detail-dialog.tsx @@ -18,6 +18,7 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API */ import { useTranslation } from 'react-i18next' import { RichContent } from '@/components/rich-content' +import { getRenderableContentKind } from '@/lib/renderable-content' import { formatDateTimeObject } from '@/lib/time' import { Dialog, @@ -28,6 +29,10 @@ import { } from '@/components/ui/dialog' import { ScrollArea } from '@/components/ui/scroll-area' +function getRichContentMode(content: string): 'html' | 'markdown' { + return getRenderableContentKind(content) === 'html' ? 'html' : 'markdown' +} + interface AnnouncementDetailModalProps { open: boolean onOpenChange: (open: boolean) => void @@ -63,7 +68,10 @@ export function AnnouncementDetailModal({ {announcement?.content && (

{t('Content')}

- +
)} {announcement?.extra && ( @@ -73,6 +81,7 @@ export function AnnouncementDetailModal({
diff --git a/web/default/src/features/home/index.tsx b/web/default/src/features/home/index.tsx index ebd0731..fc0d635 100644 --- a/web/default/src/features/home/index.tsx +++ b/web/default/src/features/home/index.tsx @@ -19,7 +19,10 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API import { useTranslation } from 'react-i18next' import { RichContent } from '@/components/rich-content' import { useAuthStore } from '@/stores/auth-store' -import { getRenderableContentKind } from '@/lib/renderable-content' +import { + getRenderableContentKind, + getSafeIframeEmbedSrc, +} from '@/lib/renderable-content' import { PublicLayout } from '@/components/layout' import { Footer } from '@/components/layout/components/footer' import { CTA, Features, Hero, HowItWorks, Stats } from './components' @@ -32,6 +35,8 @@ export function Home() { const { content, isLoaded } = useHomePageContent() const customContent = content.trim() const contentKind = getRenderableContentKind(customContent) + const iframeEmbedSrc = + contentKind === 'url' ? customContent : getSafeIframeEmbedSrc(customContent) if (!isLoaded) { return ( @@ -47,12 +52,12 @@ export function Home() { return (
- {contentKind === 'url' ? ( + {iframeEmbedSrc ? ( ' + ), + 'https://example.com/home.html' + ) + }) + + test('extracts an iframe src when the snippet only has wrapper elements', () => { + assert.equal( + getSafeIframeEmbedSrc( + '
' + ), + 'https://example.com/home.html' + ) + }) + + test('rejects iframe snippets with additional HTML content', () => { + assert.equal( + getSafeIframeEmbedSrc( + '

Welcome

' + ), + null + ) + }) + + test('rejects non-http iframe src values', () => { + assert.equal( + getSafeIframeEmbedSrc(''), + null + ) + }) +}) diff --git a/web/default/src/lib/renderable-content.ts b/web/default/src/lib/renderable-content.ts index 33c9392..ca6b040 100644 --- a/web/default/src/lib/renderable-content.ts +++ b/web/default/src/lib/renderable-content.ts @@ -32,6 +32,35 @@ export function getRenderableContentKind(value: string): RenderableContentKind { return 'markdown' } +export function getSafeIframeEmbedSrc(content: string): string | null { + if (!/]/i.test(content)) return null + if (typeof DOMParser === 'undefined') return null + + const document = new DOMParser().parseFromString(content, 'text/html') + const iframes = document.querySelectorAll('iframe') + + if (iframes.length !== 1) return null + + const iframe = iframes[0] + iframe.remove() + + if (document.body.textContent?.trim()) return null + if (!hasOnlyEmptyWrapperElements(document.body)) return null + + const src = iframe.getAttribute('src')?.trim() + if (!src) return null + + return isHttpUrl(src) ? src : null +} + +function hasOnlyEmptyWrapperElements(body: HTMLElement): boolean { + const allowedWrapperTags = new Set(['ARTICLE', 'DIV', 'MAIN', 'SECTION']) + + return [...body.querySelectorAll('*')].every((element) => + allowedWrapperTags.has(element.tagName) + ) +} + function isHttpUrl(value: string): boolean { try { const url = new URL(value) From 1c8e244e31005d1fc8b614d6052bf5ae55bf1724 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Tue, 30 Jun 2026 02:02:13 +0800 Subject: [PATCH 4/5] fix some bugs --- model/log.go | 41 ++- model/log_test.go | 242 ++++++++++++++++++ model/main.go | 102 ++++++++ model/task_cas_test.go | 1 + .../src/components/html-content.test.ts | 2 +- .../src/components/ui/markdown.test.ts | 16 +- web/default/src/components/ui/markdown.tsx | 46 +--- web/default/src/features/about/index.tsx | 10 +- web/default/src/features/home/index.tsx | 3 +- .../src/features/legal/legal-document.tsx | 19 +- web/default/src/features/playground/index.tsx | 41 ++- .../playground/lib/query-state.test.ts | 38 +++ .../features/playground/lib/query-state.ts | 28 ++ .../src/features/usage-logs/lib/utils.test.ts | 6 + .../src/features/usage-logs/lib/utils.ts | 5 +- web/default/src/features/usage-logs/types.ts | 1 + .../src/hooks/notification-utils.test.ts | 113 ++++++-- web/default/src/hooks/notification-utils.ts | 63 +++-- web/default/src/hooks/use-notifications.ts | 89 +++++-- web/default/src/lib/html-sanitizer.ts | 41 +-- .../src/lib/renderable-content.test.ts | 7 + web/default/src/lib/renderable-content.ts | 5 +- web/default/src/lib/sanitize-core.ts | 68 +++++ .../src/stores/notification-store.test.ts | 151 +++++++++++ web/default/src/stores/notification-store.ts | 145 +++++++++-- 25 files changed, 1072 insertions(+), 211 deletions(-) create mode 100644 web/default/src/features/playground/lib/query-state.test.ts create mode 100644 web/default/src/features/playground/lib/query-state.ts create mode 100644 web/default/src/lib/sanitize-core.ts create mode 100644 web/default/src/stores/notification-store.test.ts diff --git a/model/log.go b/model/log.go index 2425848..8c09db8 100644 --- a/model/log.go +++ b/model/log.go @@ -53,9 +53,27 @@ type Log struct { RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"` UpstreamRequestId string `json:"upstream_request_id,omitempty" gorm:"type:varchar(128);index:idx_logs_upstream_request_id;default:''"` Other string `json:"other"` + IsRetry bool `json:"is_retry" gorm:"default:false;index"` LogId int `json:"log_id,omitempty" gorm:"-"` } +func (log *Log) BeforeCreate(*gorm.DB) error { + log.syncRetryMarker() + return nil +} + +func (log *Log) BeforeSave(*gorm.DB) error { + log.syncRetryMarker() + return nil +} + +func (log *Log) syncRetryMarker() { + if log == nil { + return + } + log.IsRetry = logOtherHasRetryMarker(log.Other) +} + // don't use iota, avoid change log type value const ( LogTypeUnknown = 0 @@ -78,11 +96,7 @@ func applyLogTypeFilter(tx *gorm.DB, logType int) *gorm.DB { } func applyRetryLogFilter(tx *gorm.DB) *gorm.DB { - retryPatterns := []string{ - `%"retry_log":true%`, - `%"empty_retry":true%`, - } - return tx.Where("(logs.other LIKE ? OR logs.other LIKE ?)", retryPatterns[0], retryPatterns[1]) + return tx.Where("logs.is_retry = ?", true) } func applyLogFilter(tx *gorm.DB, filter string) *gorm.DB { @@ -164,6 +178,23 @@ func stripLogsAuditContent(logs []*Log) { } } +func logOtherHasRetryMarker(other string) bool { + if strings.TrimSpace(other) == "" { + return false + } + otherMap, err := common.StrToMap(other) + if err != nil || otherMap == nil { + return false + } + if retryLog, ok := otherMap["retry_log"].(bool); ok && retryLog { + return true + } + if emptyRetry, ok := otherMap["empty_retry"].(bool); ok && emptyRetry { + return true + } + return false +} + func formatUserLog(log *Log) { if log == nil { return diff --git a/model/log_test.go b/model/log_test.go index d48e1d6..26e9675 100644 --- a/model/log_test.go +++ b/model/log_test.go @@ -1,11 +1,14 @@ package model import ( + "os" "testing" "time" "github.com/MAX-API-Next/MAX-API/common" + "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" + "gorm.io/gorm" ) func withLogAuditSettings(t *testing.T, requestEnabled bool, responseEnabled bool) { @@ -70,6 +73,245 @@ func TestSumUsedQuotaRetryFilter(t *testing.T) { require.Equal(t, 71, stat.Tpm) } +func TestRetryFilterIgnoresNestedRetryMarker(t *testing.T) { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + t.Cleanup(func() { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + }) + + logs := []Log{ + { + UserId: 1, + CreatedAt: time.Now().Unix() - 10, + Type: LogTypeConsume, + Quota: 300, + PromptTokens: 3, + CompletionTokens: 7, + Other: common.MapToJsonStr(map[string]interface{}{ + "admin_info": map[string]interface{}{ + "request_content": `user prompt literally contains "retry_log":true`, + "retry_log": true, + }, + }), + }, + { + UserId: 1, + CreatedAt: time.Now().Unix() - 20, + Type: LogTypeConsume, + Quota: 200, + PromptTokens: 5, + CompletionTokens: 11, + Other: common.MapToJsonStr(map[string]interface{}{ + "retry_log": true, + }), + }, + } + require.NoError(t, LOG_DB.Create(&logs).Error) + + got, total, err := GetAllLogs(LogTypeUnknown, LogFilterRetry, 0, 0, "", "", "", 0, 10, 0, "", "", "") + require.NoError(t, err) + require.EqualValues(t, 1, total) + require.Len(t, got, 1) + require.Equal(t, logs[1].Id, got[0].Id) + + stat, err := SumUsedQuota(LogTypeUnknown, LogFilterRetry, 0, 0, "", "", "", 0, "") + require.NoError(t, err) + require.Equal(t, 200, stat.Quota) + require.Equal(t, 1, stat.Rpm) + require.Equal(t, 16, stat.Tpm) +} + +func TestBackfillLogRetryMarkerUsesTopLevelMarkersOnly(t *testing.T) { + markerKey := logRetryMarkerBackfillCompletionKey() + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + require.NoError(t, DB.Where(commonKeyCol+" = ?", markerKey).Delete(&Option{}).Error) + t.Cleanup(func() { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + require.NoError(t, DB.Where(commonKeyCol+" = ?", markerKey).Delete(&Option{}).Error) + }) + + logs := []Log{ + { + UserId: 1, + Type: LogTypeConsume, + Other: common.MapToJsonStr(map[string]interface{}{ + "retry_log": true, + }), + }, + { + UserId: 1, + Type: LogTypeConsume, + Other: common.MapToJsonStr(map[string]interface{}{ + "admin_info": map[string]interface{}{ + "retry_log": true, + }, + }), + }, + } + require.NoError(t, LOG_DB.Create(&logs).Error) + require.NoError(t, LOG_DB.Model(&Log{}).Where("1 = 1").UpdateColumn("is_retry", false).Error) + + require.NoError(t, backfillLogRetryMarker()) + + var reloaded []Log + require.NoError(t, LOG_DB.Order("id asc").Find(&reloaded).Error) + require.Len(t, reloaded, 2) + require.True(t, reloaded[0].IsRetry) + require.False(t, reloaded[1].IsRetry) +} + +func TestBackfillLogRetryMarkerSkipsAfterCompletionMarker(t *testing.T) { + markerKey := logRetryMarkerBackfillCompletionKey() + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + require.NoError(t, DB.Where(commonKeyCol+" = ?", markerKey).Delete(&Option{}).Error) + t.Cleanup(func() { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + require.NoError(t, DB.Where(commonKeyCol+" = ?", markerKey).Delete(&Option{}).Error) + }) + + log := Log{ + UserId: 1, + Type: LogTypeConsume, + Other: common.MapToJsonStr(map[string]interface{}{ + "retry_log": true, + }), + } + require.NoError(t, LOG_DB.Create(&log).Error) + require.NoError(t, LOG_DB.Model(&Log{}).Where("1 = 1").UpdateColumn("is_retry", false).Error) + require.NoError(t, backfillLogRetryMarker()) + + var marker Option + require.NoError(t, DB.First(&marker, commonKeyCol+" = ?", markerKey).Error) + require.Equal(t, "true", marker.Value) + + require.NoError(t, LOG_DB.Model(&Log{}).Where("1 = 1").UpdateColumn("is_retry", false).Error) + require.NoError(t, backfillLogRetryMarker()) + + var reloaded Log + require.NoError(t, LOG_DB.First(&reloaded, log.Id).Error) + require.False(t, reloaded.IsRetry) +} + +func TestBackfillLogRetryMarkerCompletionIsScopedToLogDBIdentity(t *testing.T) { + originalDB := DB + originalLOGDB := LOG_DB + originalLogSQLType := common.LogSqlType + t.Cleanup(func() { + DB = originalDB + LOG_DB = originalLOGDB + common.LogSqlType = originalLogSQLType + initCol() + }) + + mainDB := newRetryBackfillTestDB(t, &Option{}) + logOneDB := newRetryBackfillTestDB(t, &Log{}) + logTwoDB := newRetryBackfillTestDB(t, &Log{}) + + DB = mainDB + common.LogSqlType = common.DatabaseTypeSQLite + + t.Setenv("LOG_SQL_DSN", "sqlite://log-one") + initCol() + LOG_DB = logOneDB + logOne := Log{ + UserId: 1, + Type: LogTypeConsume, + Other: common.MapToJsonStr(map[string]interface{}{ + "retry_log": true, + }), + } + require.NoError(t, LOG_DB.Create(&logOne).Error) + require.NoError(t, LOG_DB.Model(&Log{}).Where("1 = 1").UpdateColumn("is_retry", false).Error) + require.NoError(t, backfillLogRetryMarker()) + + var reloaded Log + require.NoError(t, logOneDB.First(&reloaded, logOne.Id).Error) + require.True(t, reloaded.IsRetry) + + require.NoError(t, os.Setenv("LOG_SQL_DSN", "sqlite://log-two")) + initCol() + LOG_DB = logTwoDB + logTwo := Log{ + UserId: 1, + Type: LogTypeConsume, + Other: common.MapToJsonStr(map[string]interface{}{ + "retry_log": true, + }), + } + require.NoError(t, LOG_DB.Create(&logTwo).Error) + require.NoError(t, LOG_DB.Model(&Log{}).Where("1 = 1").UpdateColumn("is_retry", false).Error) + + require.NoError(t, backfillLogRetryMarker()) + + reloaded = Log{} + require.NoError(t, logTwoDB.First(&reloaded, logTwo.Id).Error) + require.True(t, reloaded.IsRetry) +} + +func TestLogRetryMarkerCompletionKeyUsesHashedLogDBIdentity(t *testing.T) { + originalLogSQLType := common.LogSqlType + t.Cleanup(func() { + common.LogSqlType = originalLogSQLType + }) + + common.LogSqlType = common.DatabaseTypeMySQL + t.Setenv("LOG_SQL_DSN", "user:secret@tcp(log-one:3306)/logs") + firstKey := logRetryMarkerBackfillCompletionKey() + require.NotEqual(t, logRetryMarkerBackfillOptionKey, firstKey) + require.NotContains(t, firstKey, "secret") + require.NotContains(t, firstKey, "log-one") + + require.NoError(t, os.Setenv("LOG_SQL_DSN", "user:secret@tcp(log-two:3306)/logs")) + secondKey := logRetryMarkerBackfillCompletionKey() + require.NotEqual(t, firstKey, secondKey) +} + +func TestLogRetryMarkerIsRecomputedWhenOtherChanges(t *testing.T) { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + t.Cleanup(func() { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + }) + + log := Log{ + UserId: 1, + Type: LogTypeConsume, + Other: common.MapToJsonStr(map[string]interface{}{ + "retry_log": true, + }), + } + require.NoError(t, LOG_DB.Create(&log).Error) + require.True(t, log.IsRetry) + + log.Other = common.MapToJsonStr(map[string]interface{}{ + "admin_info": map[string]interface{}{ + "use_channel": []string{"1"}, + }, + }) + require.NoError(t, LOG_DB.Save(&log).Error) + + var reloaded Log + require.NoError(t, LOG_DB.First(&reloaded, log.Id).Error) + require.False(t, reloaded.IsRetry) +} + +func newRetryBackfillTestDB(t *testing.T, models ...interface{}) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + + sqlDB, err := db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + t.Cleanup(func() { + require.NoError(t, sqlDB.Close()) + }) + + require.NoError(t, db.AutoMigrate(models...)) + + return db +} + func createRetryFilterLogs(t *testing.T) []Log { t.Helper() diff --git a/model/main.go b/model/main.go index a5ae77f..c8c5a0c 100644 --- a/model/main.go +++ b/model/main.go @@ -1,6 +1,7 @@ package model import ( + "errors" "fmt" "log" "os" @@ -25,6 +26,22 @@ var commonFalseVal string var logKeyCol string var logGroupCol string +const logRetryMarkerBackfillOptionKey = "LogRetryMarkerBackfillCompleted" + +func logRetryMarkerBackfillCompletionKey() string { + logDSN := strings.TrimSpace(os.Getenv("LOG_SQL_DSN")) + if logDSN == "" { + return logRetryMarkerBackfillOptionKey + } + + if strings.HasPrefix(logDSN, "local") { + logDSN = logDSN + "\n" + common.SQLitePath + } + + fingerprint := common.Sha1([]byte(common.LogSqlType + "\n" + logDSN)) + return logRetryMarkerBackfillOptionKey + ":" + fingerprint +} + func initCol() { // init common column names if common.UsingPostgreSQL { @@ -296,6 +313,12 @@ func migrateDB() error { return err } } + if os.Getenv("LOG_SQL_DSN") == "" { + LOG_DB = DB + if err := backfillLogRetryMarker(); err != nil { + return err + } + } return nil } @@ -376,9 +399,88 @@ func migrateLOGDB() error { if err = LOG_DB.AutoMigrate(&Log{}); err != nil { return err } + if err = backfillLogRetryMarker(); err != nil { + return err + } return nil } +func backfillLogRetryMarker() error { + if LOG_DB == nil || !LOG_DB.Migrator().HasColumn(&Log{}, "is_retry") { + return nil + } + if completed, err := isLogRetryMarkerBackfillCompleted(); err == nil && completed { + return nil + } else if err != nil { + common.SysLog("failed to check log retry marker backfill status: " + err.Error()) + } + + const batchSize = 500 + var lastId int + for { + var logs []Log + if err := LOG_DB. + Select("id", "other", "is_retry"). + Where("id > ? AND is_retry = ?", lastId, false). + Order("id asc"). + Limit(batchSize). + Find(&logs).Error; err != nil { + return err + } + if len(logs) == 0 { + return markLogRetryMarkerBackfillCompleted() + } + + retryIds := make([]int, 0, len(logs)) + for _, log := range logs { + lastId = log.Id + if log.IsRetry { + continue + } + if logOtherHasRetryMarker(log.Other) { + retryIds = append(retryIds, log.Id) + } + } + if len(retryIds) > 0 { + if err := LOG_DB.Model(&Log{}). + Where("id IN ?", retryIds). + Update("is_retry", true).Error; err != nil { + return err + } + } + } +} + +func isLogRetryMarkerBackfillCompleted() (bool, error) { + if DB == nil || !DB.Migrator().HasTable(&Option{}) { + return false, nil + } + + var option Option + err := DB.First(&option, commonKeyCol+" = ?", logRetryMarkerBackfillCompletionKey()).Error + if err == nil { + return option.Value == "true", nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, err +} + +func markLogRetryMarkerBackfillCompleted() error { + if DB == nil || !DB.Migrator().HasTable(&Option{}) { + return nil + } + + markerKey := logRetryMarkerBackfillCompletionKey() + option := Option{Key: markerKey} + if err := DB.FirstOrCreate(&option, Option{Key: markerKey}).Error; err != nil { + return err + } + option.Value = "true" + return DB.Save(&option).Error +} + type sqliteColumnDef struct { Name string DDL string diff --git a/model/task_cas_test.go b/model/task_cas_test.go index 3aff847..cb3686b 100644 --- a/model/task_cas_test.go +++ b/model/task_cas_test.go @@ -39,6 +39,7 @@ func TestMain(m *testing.M) { &User{}, &Token{}, &Log{}, + &Option{}, &Channel{}, &Ability{}, &Redemption{}, diff --git a/web/default/src/components/html-content.test.ts b/web/default/src/components/html-content.test.ts index 1a31d3e..8e7133c 100644 --- a/web/default/src/components/html-content.test.ts +++ b/web/default/src/components/html-content.test.ts @@ -22,7 +22,7 @@ import DOMPurify from 'dompurify' import { JSDOM } from 'jsdom' import { sanitizeHtmlContent } from '@/lib/html-sanitizer' -const dom = new JSDOM('') +const dom = new JSDOM('', { url: 'http://localhost/' }) const previousWindowDescriptor = Object.getOwnPropertyDescriptor( globalThis, 'window' diff --git a/web/default/src/components/ui/markdown.test.ts b/web/default/src/components/ui/markdown.test.ts index a32080c..72d1c0b 100644 --- a/web/default/src/components/ui/markdown.test.ts +++ b/web/default/src/components/ui/markdown.test.ts @@ -19,17 +19,17 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API import assert from 'node:assert/strict' import { describe, test } from 'node:test' import { JSDOM } from 'jsdom' -import { renderMarkdownForTest } from './markdown' +import { renderMarkdown } from './markdown' -const dom = new JSDOM('') +const dom = new JSDOM('', { url: 'http://localhost/' }) Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window, }) -describe('renderMarkdownForTest', () => { +describe('renderMarkdown', () => { test('renders markdown links without losing the marked parser context', () => { - const html = renderMarkdownForTest('[MAX API](https://example.com)') + const html = renderMarkdown('[MAX API](https://example.com)') assert.match(html, / { test('falls back to link text for invalid href values', () => { const badHref = `http://example.com/${String.fromCharCode(0xd800)}` - const html = renderMarkdownForTest(`[Broken](${badHref})`) + const html = renderMarkdown(`[Broken](${badHref})`) assert.equal(html, '

Broken

\n') }) test('sanitizes hostile html when rendered outside the browser', () => { - const html = renderMarkdownForTest('') + const html = renderMarkdown('') assert.doesNotMatch(html, /onerror/i) assert.doesNotMatch(html, /alert\(1\)/i) @@ -66,7 +66,7 @@ describe('renderMarkdownForTest', () => { ] cases.forEach(({ label, markdown, unsafe }) => { - const html = renderMarkdownForTest(markdown) + const html = renderMarkdown(markdown) assert.doesNotMatch(html, unsafe) assert.doesNotMatch(html, /href=/i) @@ -82,7 +82,7 @@ describe('renderMarkdownForTest', () => { }) try { - assert.equal(renderMarkdownForTest(''), '') + assert.equal(renderMarkdown(''), '') } finally { if (windowDescriptor) { Object.defineProperty(globalThis, 'window', windowDescriptor) diff --git a/web/default/src/components/ui/markdown.tsx b/web/default/src/components/ui/markdown.tsx index 809a559..aa63caa 100644 --- a/web/default/src/components/ui/markdown.tsx +++ b/web/default/src/components/ui/markdown.tsx @@ -16,11 +16,11 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import DOMPurify from 'dompurify' import * as katex from 'katex' import 'katex/dist/katex.min.css' import { Marked, Renderer, type MarkedExtension, type Tokens } from 'marked' import { useMemo } from 'react' +import { sanitizeHtmlWithOptions } from '@/lib/sanitize-core' import { cn } from '@/lib/utils' interface MarkdownProps { @@ -129,13 +129,6 @@ const sanitizeOptions = { ADD_TAGS: allowedTags, } as const -type DomPurifySanitizer = { - isSupported?: boolean - sanitize: (dirty: string, config?: typeof sanitizeOptions) => string -} - -type DomPurifyFactory = (window: Window) => DomPurifySanitizer - type FlowNode = { id: string label: string @@ -194,36 +187,8 @@ function normalizeUrl(value: string): string | null { } } -function isUsableSanitizer(value: unknown): value is DomPurifySanitizer { - if ((typeof value !== 'object' && typeof value !== 'function') || value === null) { - return false - } - - const sanitizer = value as Partial - return ( - sanitizer.isSupported !== false && - typeof sanitizer.sanitize === 'function' - ) -} - function sanitizeHtml(html: string): string { - const purify = DOMPurify as unknown as - | DomPurifySanitizer - | DomPurifyFactory - - if (isUsableSanitizer(purify)) { - return purify.sanitize(html, sanitizeOptions) - } - - if (typeof window !== 'undefined' && typeof purify === 'function') { - const browserSanitizer = purify(window) - - if (isUsableSanitizer(browserSanitizer)) { - return browserSanitizer.sanitize(html, sanitizeOptions) - } - } - - return '' + return sanitizeHtmlWithOptions(html, sanitizeOptions) } function normalizeMathSource(source: string): string { @@ -755,17 +720,14 @@ function createMarkdownParser() { return parser } -export function renderMarkdownForTest(markdown: string): string { +export function renderMarkdown(markdown: string): string { const markdownParser = createMarkdownParser() const parsedHtml = markdownParser.parse(markdown, markdownOptions) return sanitizeHtml(parsedHtml) } export function Markdown(props: MarkdownProps) { - const html = useMemo( - () => renderMarkdownForTest(props.children), - [props.children] - ) + const html = useMemo(() => renderMarkdown(props.children), [props.children]) return (