fix(web): history search used response shapes the real server doesn't send
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
93d4077d1e
commit
1bd8d7f804
@@ -1,45 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Icon } from '@/app/Icon';
|
import { Icon } from '@/app/Icon';
|
||||||
import { fileAuthHeaders, fileDownloadUrl } from '@/lib/api/upload';
|
import { useAuthedBlobUrl } from '@/lib/hooks';
|
||||||
import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments';
|
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 <img src="..."> or <a href="..."> 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 {
|
interface ImageData {
|
||||||
val?: string;
|
val?: string;
|
||||||
ref?: string;
|
ref?: string;
|
||||||
@@ -60,7 +24,8 @@ export function ImageAttachment({ data }: { data: ImageData }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <img src={src} alt="" className="max-h-[260px] max-w-[260px] rounded-lg object-cover" />;
|
// README §3.2: fixed 260x170, not a same-aspect-ratio-preserving max-height/width box.
|
||||||
|
return <img src={src} alt="" className="h-[170px] w-[260px] rounded-lg object-cover" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileData {
|
interface FileData {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { Icon } from '@/app/Icon';
|
import { Icon } from '@/app/Icon';
|
||||||
import { getTopicMedia, searchTopicMessages, type MediaType, type SearchFileHit, type SearchTextHit } from '@/lib/api/messages';
|
import { getTopicMedia, searchTopicMessages, type MediaFetchType, type MediaItem, type MediaType, type SearchHit } from '@/lib/api/messages';
|
||||||
import { useDebouncedValue } from '@/lib/hooks';
|
import { checkUrlSafety } from '@/lib/api/security';
|
||||||
import { formatFileSize } from '@/lib/messages/attachments';
|
import { useAuthedBlobUrl, useDebouncedValue } from '@/lib/hooks';
|
||||||
|
import { renderDrafty } from '@/lib/messages/drafty';
|
||||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||||
|
|
||||||
type Tab = 'text' | MediaType;
|
type Tab = 'text' | MediaType;
|
||||||
@@ -18,8 +19,104 @@ const TABS: { key: Tab; label: string; icon: string }[] = [
|
|||||||
{ key: 'link', label: '链接', icon: 'link' },
|
{ key: 'link', label: '链接', icon: 'link' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function isFileHit(item: SearchTextHit | SearchFileHit): item is SearchFileHit {
|
function formatDuration(ms: number | undefined): string {
|
||||||
return 'file' in item;
|
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 (
|
||||||
|
<div className="relative aspect-square overflow-hidden bg-surface-rail">
|
||||||
|
{src ? (
|
||||||
|
<img src={src} alt="" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="grid h-full w-full place-items-center">
|
||||||
|
<Icon name={remote.error ? 'broken_image' : 'image'} className="text-[24px] text-text-tertiary-3" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isVideo && src ? (
|
||||||
|
<div className="absolute inset-0 bg-black/10">
|
||||||
|
<Icon name="play_circle" className="absolute inset-0 m-auto text-[32px] text-white/90" />
|
||||||
|
{data?.duration ? (
|
||||||
|
<span className="absolute bottom-1 right-1.5 rounded bg-black/50 px-1 text-[11px] text-white">{formatDuration(data.duration)}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 <span className="text-text-tertiary-4">(内容不可用)</span>;
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-text-body">
|
||||||
|
{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<string, unknown> })),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 <SnapshotContent item={item} />;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="rounded-lg bg-surface-rail px-3.5 py-3">
|
||||||
|
<div className="text-xs text-text-tertiary-3">{formatEventTimestamp(new Date(item.ts))}</div>
|
||||||
|
{item.title ? <div className="mt-0.5 truncate text-sm font-medium text-text-body">{item.title}</div> : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={open}
|
||||||
|
disabled={safetyCheck.isPending}
|
||||||
|
className="mt-0.5 line-clamp-2 break-all text-left text-sm text-accent disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{item.url}
|
||||||
|
</button>
|
||||||
|
{item.summary ? <div className="mt-0.5 line-clamp-2 text-xs text-text-tertiary-2">{item.summary}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HistorySearchModalProps {
|
interface HistorySearchModalProps {
|
||||||
@@ -42,8 +139,14 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
|
|||||||
|
|
||||||
const mediaQuery = useQuery({
|
const mediaQuery = useQuery({
|
||||||
queryKey: ['topic-media', topicId, tab],
|
queryKey: ['topic-media', topicId, tab],
|
||||||
queryFn: () => getTopicMedia(topicId, tab as MediaType),
|
queryFn: () => getTopicMedia(topicId, tab as MediaFetchType),
|
||||||
enabled: tab !== 'text',
|
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 (
|
return (
|
||||||
@@ -100,8 +203,8 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
|
|||||||
<div className="flex items-center justify-between text-xs text-text-tertiary-3">
|
<div className="flex items-center justify-between text-xs text-text-tertiary-3">
|
||||||
<span>{formatEventTimestamp(new Date(item.ts))}</span>
|
<span>{formatEventTimestamp(new Date(item.ts))}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 truncate text-sm text-text-body">
|
<div className="mt-0.5 truncate">
|
||||||
{isFileHit(item) ? `[文件] ${item.file.name} · ${formatFileSize(item.file.size)}` : item.snippet}
|
<SnapshotContent item={item} />
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -109,22 +212,34 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : tab === 'image' || tab === 'video' ? (
|
||||||
<div className="flex-1 overflow-y-auto p-5">
|
<div className="flex-1 overflow-y-auto p-5">
|
||||||
{mediaQuery.isLoading ? (
|
{mediaQuery.isLoading ? (
|
||||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||||
) : !mediaQuery.data?.items.length ? (
|
) : !mediaQuery.data?.items.length ? (
|
||||||
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无内容</div>
|
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无内容</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-0.5 overflow-hidden rounded-lg">
|
||||||
{mediaQuery.data.items.map((item) => (
|
{mediaQuery.data.items.map((item) => (
|
||||||
<div key={item.seq} className="flex aspect-square items-center justify-center rounded-lg bg-surface-rail">
|
<MediaThumb key={item.seq} item={item} isVideo={tab === 'video'} />
|
||||||
<Icon name={TABS.find((t) => t.key === tab)?.icon || 'description'} className="text-[28px] text-text-tertiary-3" />
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 overflow-y-auto p-5">
|
||||||
|
{fileLinkQuery.isLoading ? (
|
||||||
|
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||||
|
) : !fileLinkQuery.data?.items.length ? (
|
||||||
|
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无内容</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{fileLinkQuery.data.items.map((item) =>
|
||||||
|
tab === 'link' ? <LinkResult key={item.seq} item={item} /> : <SnapshotContent key={item.seq} item={item} />,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+66
-35
@@ -25,45 +25,76 @@ export function recallMessage(topic: string, seq: number, version = 1, reason?:
|
|||||||
return imPost<RecallResult>(`/topics/${encodeURIComponent(topic)}/messages/${seq}/recall`, { version, reason });
|
return imPost<RecallResult>(`/topics/${encodeURIComponent(topic)}/messages/${seq}/recall`, { version, reason });
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchTextHit {
|
export interface DraftyEntityData {
|
||||||
seq: number;
|
ref?: string;
|
||||||
from: string;
|
val?: string;
|
||||||
ts: string;
|
mime?: string;
|
||||||
snippet: string;
|
name?: string;
|
||||||
matches?: Array<{ start: number; end: number }>;
|
size?: 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<SearchTextHit | SearchFileHit>; 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;
|
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
ts: string;
|
duration?: number;
|
||||||
availability: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** README §3.4's 4-icon grid (图片/视频/文件/链接). */
|
export interface DraftyEntity {
|
||||||
export function getTopicMedia(topic: string, type: MediaType, cursor = '') {
|
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 });
|
const qs = new URLSearchParams({ type });
|
||||||
if (cursor) qs.set('cursor', cursor);
|
if (cursor) qs.set('cursor', cursor);
|
||||||
return imGet<{ topic: string; items: MediaItem[]; next_cursor?: string }>(`/topics/${encodeURIComponent(topic)}/media?${qs.toString()}`);
|
return imGet<{ topic: string; items: MediaItem[]; next_cursor?: string }>(`/topics/${encodeURIComponent(topic)}/media?${qs.toString()}`);
|
||||||
|
|||||||
@@ -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<UrlSafetyResult>('/security/urls/check', { url, source });
|
||||||
|
}
|
||||||
@@ -1,5 +1,44 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 <img src="..."> or <a href="..."> 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). */
|
/** Ported from the old app's use-cached-data.js -- debounces a fast-changing value (e.g. search input). */
|
||||||
export function useDebouncedValue<T>(value: T, delay = 300): T {
|
export function useDebouncedValue<T>(value: T, delay = 300): T {
|
||||||
const [debounced, setDebounced] = useState(value);
|
const [debounced, setDebounced] = useState(value);
|
||||||
|
|||||||
Reference in New Issue
Block a user