From 1bd8d7f8042100794f721e276d2d2ab3074bb9b6 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 8 Sep 2026 03:58:22 +0800 Subject: [PATCH] fix(web): history search used response shapes the real server doesn't send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed live against the real backend: docs/IM-前后端对接文档.md's flat `/media` items ({thumbnail_file_id,file_id,width,height}) and flat search hits ({file:{...}}, {url,title,...}) don't match what the server actually returns. Both endpoints really return the raw original message (`{txt,fmt, ent}`, search hits wrapped one level deeper in `snapshot`) -- the same shape lib/messages/drafty.tsx's renderDrafty already knows how to render, reused here instead of hand-picking fields from a shape that doesn't exist. This is also why image/video thumbnails were rendering as generic icons: the field they were reading was never present in a real response. Extracted useAuthedBlobUrl (authenticated file-fetch-to-blob-URL) out of AttachmentPreview into lib/hooks.ts so the history modal's own thumbnail grid can reuse it instead of duplicating the fetch/header logic. Also adds the URL safety-check gate (docs/IM-前后端对接文档.md:1822,1898) before opening a link found via the 链接 tab, which this view didn't have at all before. Co-Authored-By: Claude Sonnet 5 --- .../features/messages/AttachmentPreview.tsx | 41 +---- .../features/messages/HistorySearchModal.tsx | 145 ++++++++++++++++-- web/src/lib/api/messages.ts | 101 +++++++----- web/src/lib/api/security.ts | 17 ++ web/src/lib/hooks.ts | 39 +++++ 5 files changed, 255 insertions(+), 88 deletions(-) create mode 100644 web/src/lib/api/security.ts diff --git a/web/src/features/messages/AttachmentPreview.tsx b/web/src/features/messages/AttachmentPreview.tsx index 920d1c83..5a01e336 100644 --- a/web/src/features/messages/AttachmentPreview.tsx +++ b/web/src/features/messages/AttachmentPreview.tsx @@ -1,45 +1,9 @@ import { useEffect, useState } from 'react'; import { Icon } from '@/app/Icon'; -import { fileAuthHeaders, fileDownloadUrl } from '@/lib/api/upload'; +import { useAuthedBlobUrl } from '@/lib/hooks'; import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments'; -/** - * GET /v0/file/s/{file_id} requires the same auth header as upload (see upload.ts's fileAuthHeaders - * for exactly which one, and why) -- a plain or can't attach a - * custom header, so an externally-referenced (non-inline) attachment needs an authenticated fetch - * into a blob URL rather than a direct URL. Inline (base64 `val`) attachments skip this path entirely. - */ -function useAuthedBlobUrl(ref: string | undefined): { url: string | null; loading: boolean; error: boolean } { - const [state, setState] = useState<{ url: string | null; loading: boolean; error: boolean }>({ url: null, loading: !!ref, error: false }); - - useEffect(() => { - if (!ref) return undefined; - let cancelled = false; - let objectUrl: string | null = null; - setState({ url: null, loading: true, error: false }); - fetch(fileDownloadUrl(ref), { headers: fileAuthHeaders() }) - .then((res) => { - if (!res.ok) throw new Error('download failed'); - return res.blob(); - }) - .then((blob) => { - if (cancelled) return; - objectUrl = URL.createObjectURL(blob); - setState({ url: objectUrl, loading: false, error: false }); - }) - .catch(() => { - if (!cancelled) setState({ url: null, loading: false, error: true }); - }); - return () => { - cancelled = true; - if (objectUrl) URL.revokeObjectURL(objectUrl); - }; - }, [ref]); - - return state; -} - interface ImageData { val?: string; ref?: string; @@ -60,7 +24,8 @@ export function ImageAttachment({ data }: { data: ImageData }) { ); } - return ; + // README §3.2: fixed 260x170, not a same-aspect-ratio-preserving max-height/width box. + return ; } interface FileData { diff --git a/web/src/features/messages/HistorySearchModal.tsx b/web/src/features/messages/HistorySearchModal.tsx index 76b9b56d..77a44549 100644 --- a/web/src/features/messages/HistorySearchModal.tsx +++ b/web/src/features/messages/HistorySearchModal.tsx @@ -1,11 +1,12 @@ import { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { Icon } from '@/app/Icon'; -import { getTopicMedia, searchTopicMessages, type MediaType, type SearchFileHit, type SearchTextHit } from '@/lib/api/messages'; -import { useDebouncedValue } from '@/lib/hooks'; -import { formatFileSize } from '@/lib/messages/attachments'; +import { getTopicMedia, searchTopicMessages, type MediaFetchType, type MediaItem, type MediaType, type SearchHit } from '@/lib/api/messages'; +import { checkUrlSafety } from '@/lib/api/security'; +import { useAuthedBlobUrl, useDebouncedValue } from '@/lib/hooks'; +import { renderDrafty } from '@/lib/messages/drafty'; import { formatEventTimestamp } from '@/lib/messages/time'; type Tab = 'text' | MediaType; @@ -18,8 +19,104 @@ const TABS: { key: Tab; label: string; icon: string }[] = [ { key: 'link', label: '链接', icon: 'link' }, ]; -function isFileHit(item: SearchTextHit | SearchFileHit): item is SearchFileHit { - return 'file' in item; +function formatDuration(ms: number | undefined): string { + const totalSeconds = Math.round((ms || 0) / 1000); + return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`; +} + +/** README §3.4 grid thumbnails -- same inline-vs-authenticated-fetch duality as AttachmentPreview's + * ImageAttachment (a grid cell needs its own aspect-square crop, so this doesn't reuse that + * component directly, but reads the same `ent[0].data.{val,ref}` shape). */ +function MediaThumb({ item, isVideo }: { item: MediaItem; isVideo: boolean }) { + const data = item.ent?.[0]?.data; + const inline = typeof data?.val === 'string' ? `data:${data.mime || 'image/jpeg'};base64,${data.val}` : null; + const remote = useAuthedBlobUrl(inline ? undefined : data?.ref); + const src = inline || remote.url; + + return ( +
+ {src ? ( + + ) : ( +
+ +
+ )} + {isVideo && src ? ( +
+ + {data?.duration ? ( + {formatDuration(data.duration)} + ) : null} +
+ ) : null} +
+ ); +} + +/** Renders a search hit's original message content (text/file/voice/...) via the same Drafty + * formatter regular chat bubbles use -- `snapshot.content` is a real, complete Drafty document + * (docs/IM-前后端对接文档.md's flat `SearchFileHit`-style shape doesn't actually exist on this + * server; see messages.ts's SearchHit doc comment), so this passes it straight through rather than + * picking fields back out of it by hand. */ +function SnapshotContent({ item }: { item: SearchHit }) { + const content = item.snapshot?.content; + if (!content) return (内容不可用); + return ( +
+ {renderDrafty({ + txt: content.txt || ' ', + fmt: (content.fmt as { at?: number; len?: number; tp?: string; key?: number }[] | undefined) || [{ at: -1 }], + ent: (content.ent || []).map((e) => ({ tp: e.tp || '', data: (e.data || {}) as Record })), + })} +
+ ); +} + +/** + * docs/IM-前后端对接文档.md:1822,1898 -- links found in chat history must pass a server-side risk + * check before opening (the server fetches/classifies the URL out-of-band; the client never probes + * it directly). `warn` still lets the user proceed after an explicit confirm; `block` does not. + * This only guards the enrichment shape (`item.url` etc, per docs) -- if a hit doesn't carry that + * (unconfirmed live, see SearchHit's doc comment), it falls back to the plain snapshot rendering, + * whose inline links aren't gated here (same as any other chat-bubble link). + */ +function LinkResult({ item }: { item: SearchHit & { url?: string; title?: string; summary?: string } }) { + const safetyCheck = useMutation({ mutationFn: (url: string) => checkUrlSafety(url, 'history_search') }); + + if (!item.url) return ; + + const open = async () => { + let result; + try { + result = await safetyCheck.mutateAsync(item.url!); + } catch { + window.alert('链接安全检查失败,请稍后重试。'); + return; + } + if (result.decision === 'block') { + window.alert('该链接存在安全风险,已被拦截。'); + return; + } + if (result.decision === 'warn' && !window.confirm('该链接可能存在风险,是否仍要打开?')) return; + window.open(item.url, '_blank', 'noopener,noreferrer'); + }; + + return ( +
+
{formatEventTimestamp(new Date(item.ts))}
+ {item.title ?
{item.title}
: null} + + {item.summary ?
{item.summary}
: null} +
+ ); } interface HistorySearchModalProps { @@ -42,8 +139,14 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi const mediaQuery = useQuery({ queryKey: ['topic-media', topicId, tab], - queryFn: () => getTopicMedia(topicId, tab as MediaType), - enabled: tab !== 'text', + queryFn: () => getTopicMedia(topicId, tab as MediaFetchType), + enabled: tab === 'image' || tab === 'video', + }); + + const fileLinkQuery = useQuery({ + queryKey: ['topic-search-media', topicId, tab], + queryFn: () => searchTopicMessages(topicId, '', tab as 'file' | 'link'), + enabled: tab === 'file' || tab === 'link', }); return ( @@ -100,8 +203,8 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
{formatEventTimestamp(new Date(item.ts))}
-
- {isFileHit(item) ? `[文件] ${item.file.name} · ${formatFileSize(item.file.size)}` : item.snippet} +
+
))} @@ -109,22 +212,34 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi )}
- ) : ( + ) : tab === 'image' || tab === 'video' ? (
{mediaQuery.isLoading ? (
加载中…
) : !mediaQuery.data?.items.length ? (
暂无内容
) : ( -
+
{mediaQuery.data.items.map((item) => ( -
- t.key === tab)?.icon || 'description'} className="text-[28px] text-text-tertiary-3" /> -
+ ))}
)}
+ ) : ( +
+ {fileLinkQuery.isLoading ? ( +
加载中…
+ ) : !fileLinkQuery.data?.items.length ? ( +
暂无内容
+ ) : ( +
+ {fileLinkQuery.data.items.map((item) => + tab === 'link' ? : , + )} +
+ )} +
)}
diff --git a/web/src/lib/api/messages.ts b/web/src/lib/api/messages.ts index 3aa58789..59977671 100644 --- a/web/src/lib/api/messages.ts +++ b/web/src/lib/api/messages.ts @@ -25,45 +25,76 @@ export function recallMessage(topic: string, seq: number, version = 1, reason?: return imPost(`/topics/${encodeURIComponent(topic)}/messages/${seq}/recall`, { version, reason }); } -export interface SearchTextHit { - seq: number; - from: string; - ts: string; - snippet: string; - matches?: Array<{ start: number; end: number }>; -} - -export interface SearchFileHit { - seq: number; - from: string; - ts: string; - file: { file_id: string; name: string; size: number; mime: string }; - availability: string; -} - -/** README §3.4's "查找聊天记录" -- ported endpoint shapes from docs/IM-前后端对接文档.md:1251-1252. */ -export function searchTopicMessages(topic: string, query: string, type: 'text' | 'file' = 'text', cursor = '') { - const qs = new URLSearchParams({ q: query, type }); - if (cursor) qs.set('cursor', cursor); - return imGet<{ topic: string; items: Array; next_cursor?: string }>( - `/topics/${encodeURIComponent(topic)}/messages/search?${qs.toString()}`, - ); -} - -export type MediaType = 'image' | 'video' | 'file' | 'link'; - -export interface MediaItem { - seq: number; - thumbnail_file_id?: string; - file_id?: string; +export interface DraftyEntityData { + ref?: string; + val?: string; + mime?: string; + name?: string; + size?: number; width?: number; height?: number; - ts: string; - availability: string; + duration?: number; } -/** README §3.4's 4-icon grid (图片/视频/文件/链接). */ -export function getTopicMedia(topic: string, type: MediaType, cursor = '') { +export interface DraftyEntity { + tp?: string; + data?: DraftyEntityData; +} + +/** + * docs/IM-前后端对接文档.md:1879,1889,1898 describe `file`/`link` results as flat `{file:{...}}` / + * `{url,title,...}` objects -- confirmed LIVE against the real server to be wrong. The real payload + * wraps a `snapshot` of the ORIGINAL raw message (`snapshot.content` is a full `{txt,fmt,ent}` + * Drafty document -- pass it to lib/messages/drafty.ts's renderDrafty as-is, don't reconstruct it) + * around thin search metadata. `snapshot.text` (not modeled here) is a redundant, sometimes-JSON- + * stringified fallback the server fills in for non-text messages -- do not render it. + */ +export interface SearchHit { + seq: number; + from: string; + ts: string; + availability?: string; + matches?: Array<{ start: number; end: number }>; + snippet?: string; + snapshot?: { + content?: { txt?: string; fmt?: unknown[]; ent?: DraftyEntity[] }; + }; +} + +/** + * README §3.4's "查找聊天记录" -- ported endpoint shapes from docs/IM-前后端对接文档.md:1879,1889,1898. + * `file`/`link` are this same search endpoint (not the `/media` one below) with an empty `q` -- per + * §7.13/7.14 of the protocol doc, only `image`/`video` actually live behind `/media`. + */ +export function searchTopicMessages(topic: string, query: string, type: 'text' | 'file' | 'link' = 'text', cursor = '') { + const qs = new URLSearchParams({ q: query, type }); + if (cursor) qs.set('cursor', cursor); + return imGet<{ topic: string; items: SearchHit[]; next_cursor?: string }>(`/topics/${encodeURIComponent(topic)}/messages/search?${qs.toString()}`); +} + +/** README §3.4's 4-icon grid (图片/视频/文件/链接) -- a UI-level union for "which of the 4 tabs is + * selected." Only 2 of the 4 are actually served by `/media` (see MediaFetchType below); file/link + * go through searchTopicMessages instead (docs/IM-前后端对接文档.md §7.13-7.16). */ +export type MediaType = 'image' | 'video' | 'file' | 'link'; + +/** The subset of MediaType that `/media` itself understands. */ +export type MediaFetchType = 'image' | 'video'; + +/** + * docs/IM-前后端对接文档.md:1907 describes a flat `{seq,thumbnail_file_id,file_id,width,height,ts, + * availability}` -- confirmed LIVE to be wrong the same way as SearchHit above: the real payload is + * the raw message's own `{ent,fmt,txt}` at the top level (no `snapshot` wrapper this time), so the + * actual attachment lives at `ent[0].data`, same DraftyEntityData shape as everywhere else. + */ +export interface MediaItem { + seq: number; + from?: string; + ts: string; + availability: string; + ent?: DraftyEntity[]; +} + +export function getTopicMedia(topic: string, type: MediaFetchType, cursor = '') { const qs = new URLSearchParams({ type }); if (cursor) qs.set('cursor', cursor); return imGet<{ topic: string; items: MediaItem[]; next_cursor?: string }>(`/topics/${encodeURIComponent(topic)}/media?${qs.toString()}`); diff --git a/web/src/lib/api/security.ts b/web/src/lib/api/security.ts new file mode 100644 index 00000000..63f7d412 --- /dev/null +++ b/web/src/lib/api/security.ts @@ -0,0 +1,17 @@ +// docs/IM-前后端对接文档.md:1822,1898,1943 -- links surfaced anywhere in the UI (favorites, history +// search) must go through this check before being opened; the server, not the client, holds the +// risk classification (it fetches/caches the URL server-side to avoid the client ever hitting an +// attacker-controlled address directly, which is also why there's no client-side allow/block list). +import { imPost } from './client'; + +export interface UrlSafetyResult { + url: string; + decision: 'allow' | 'warn' | 'block'; + risk_level: string; + reason?: string; + risk_challenge_id?: string; +} + +export function checkUrlSafety(url: string, source: string) { + return imPost('/security/urls/check', { url, source }); +} diff --git a/web/src/lib/hooks.ts b/web/src/lib/hooks.ts index 14724857..01ff3d98 100644 --- a/web/src/lib/hooks.ts +++ b/web/src/lib/hooks.ts @@ -1,5 +1,44 @@ import { useEffect, useState } from 'react'; +import { fileAuthHeaders, fileDownloadUrl } from '@/lib/api/upload'; + +/** + * GET /v0/file/s/{file_id} requires the same auth header as upload (see upload.ts's fileAuthHeaders + * for exactly which one, and why) -- a plain or
can't attach a + * custom header, so an externally-referenced (non-inline) attachment needs an authenticated fetch + * into a blob URL rather than a direct URL. `ref` is anything fileDownloadUrl accepts: a full + * "/v0/file/s/xxx" path (Drafty `refurl`), or an absolute URL. + */ +export function useAuthedBlobUrl(ref: string | undefined): { url: string | null; loading: boolean; error: boolean } { + const [state, setState] = useState<{ url: string | null; loading: boolean; error: boolean }>({ url: null, loading: !!ref, error: false }); + + useEffect(() => { + if (!ref) return undefined; + let cancelled = false; + let objectUrl: string | null = null; + setState({ url: null, loading: true, error: false }); + fetch(fileDownloadUrl(ref), { headers: fileAuthHeaders() }) + .then((res) => { + if (!res.ok) throw new Error('download failed'); + return res.blob(); + }) + .then((blob) => { + if (cancelled) return; + objectUrl = URL.createObjectURL(blob); + setState({ url: objectUrl, loading: false, error: false }); + }) + .catch(() => { + if (!cancelled) setState({ url: null, loading: false, error: true }); + }); + return () => { + cancelled = true; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [ref]); + + return state; +} + /** Ported from the old app's use-cached-data.js -- debounces a fast-changing value (e.g. search input). */ export function useDebouncedValue(value: T, delay = 300): T { const [debounced, setDebounced] = useState(value);