diff --git a/frontend/apps/desktop/src/components/commenting.tsx b/frontend/apps/desktop/src/components/commenting.tsx index 7fe8a823c..76285b19c 100644 --- a/frontend/apps/desktop/src/components/commenting.tsx +++ b/frontend/apps/desktop/src/components/commenting.tsx @@ -79,6 +79,7 @@ function CommentBoxImpl(props: { const pushAfterAction = usePushAfterAction() const universalClient = useUniversalClient() const {getSigner, publish} = universalClient + const importWebFile = useCallback((url: string) => client.webImporting.importWebFile.mutate(url), []) const route = useNavRoute() const navigate = useNavigate('replace') @@ -410,6 +411,7 @@ function CommentBoxImpl(props: { handleSubmit={handleSubmit} initialBlocks={draft.data?.blocks} onContentChange={handleContentChange} + importWebFile={importWebFile} handleFileAttachment={handleFileAttachment} universalClient={universalClient} domainResolver={domainResolver} @@ -456,6 +458,7 @@ function InlineEditBox({comment, onSave, onCancel, isSaving}: InlineEditCommentP const account = useSelectedAccount() const selectedAccountId = useSelectedAccountId() const universalClient = useUniversalClient() + const importWebFile = useCallback((url: string) => client.webImporting.importWebFile.mutate(url), []) const contentRef = useRef(comment.content) const handleFileAttachment = useCallback(async (file: File) => { @@ -488,6 +491,7 @@ function InlineEditBox({comment, onSave, onCancel, isSaving}: InlineEditCommentP isReplying={false} handleSubmit={handleSubmit} initialBlocks={comment.content} + importWebFile={importWebFile} handleFileAttachment={handleFileAttachment} universalClient={universalClient} domainResolver={domainResolver} diff --git a/frontend/apps/desktop/src/pages/agents/prompt-editor.tsx b/frontend/apps/desktop/src/pages/agents/prompt-editor.tsx index 95729c708..09551bd0b 100644 --- a/frontend/apps/desktop/src/pages/agents/prompt-editor.tsx +++ b/frontend/apps/desktop/src/pages/agents/prompt-editor.tsx @@ -1,6 +1,8 @@ +import {client} from '@/trpc' import {blocksToMarkdown} from '@seed-hypermedia/client' import type {HMBlockNode, HMDocument} from '@seed-hypermedia/client/hm-types' import {CommentEditor} from '@shm/editor/comment-editor' +import {useCallback} from 'react' /** Converts rich prompt blocks to the markdown sent to the agents service. */ export function promptBlocksToMarkdown(blocks: HMBlockNode[]): string { @@ -18,6 +20,8 @@ export function AgentPromptEditor({ initialBlocks: HMBlockNode[] onChange: (blocks: HMBlockNode[]) => void }) { + const importWebFile = useCallback((url: string) => client.webImporting.importWebFile.mutate(url), []) + return (
onChange(blocks)} handleSubmit={() => {}} + importWebFile={importWebFile} submitButton={() => <>} />
diff --git a/frontend/apps/desktop/src/pages/agents/session.tsx b/frontend/apps/desktop/src/pages/agents/session.tsx index 8ec9a1017..b81105ef9 100644 --- a/frontend/apps/desktop/src/pages/agents/session.tsx +++ b/frontend/apps/desktop/src/pages/agents/session.tsx @@ -24,6 +24,7 @@ import { useStopAgentSession, useUpdateAgentSession, } from '@/models/agents' +import {client} from '@/trpc' import {type ChatMessagePart, type ChatToolPart} from '@/models/chat-parts' import {useSelectedAccountId} from '@/selected-account' import {useNavigate} from '@/utils/useNavigate' @@ -666,6 +667,7 @@ function AgentRichMessageComposer({ }) { const [draftMarkdown, setDraftMarkdown] = useState('') const submitHandleRef = useRef(null) + const importWebFile = useCallback((url: string) => client.webImporting.importWebFile.mutate(url), []) async function submitRichMessage(getContent: CommentEditorGetContent, reset: () => void) { const {blockNodes} = await getContent(async () => ({blobs: [], resultCIDs: []})) @@ -691,6 +693,7 @@ function AgentRichMessageComposer({ initialBlocks={[]} onContentChange={(blocks) => setDraftMarkdown(promptBlocksToMarkdown(trimTrailingEmptyBlocks(blocks)))} handleSubmit={(getContent, reset) => void submitRichMessage(getContent, reset)} + importWebFile={importWebFile} submitButton={() => <>} /> diff --git a/frontend/apps/web/app/commenting.tsx b/frontend/apps/web/app/commenting.tsx index 35dd8a60c..8a836fac8 100644 --- a/frontend/apps/web/app/commenting.tsx +++ b/frontend/apps/web/app/commenting.tsx @@ -42,6 +42,7 @@ import {useMutation} from '@tanstack/react-query' import {Check, SendHorizontal, X} from 'lucide-react' import {ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react' import {useCommentDraftPersistence} from './comment-draft-utils' +import {fetchWebImportBlob} from './document-edit/web-image-upload' import {EmailNotificationsForm} from './email-notifications' import {hasPromptedEmailNotifications, setHasPromptedEmailNotifications, setPendingIntent} from './local-db' import {processPendingIntent} from './pending-intent' @@ -569,22 +570,14 @@ async function importWebFile( size: number }> { try { - const res = await fetch(url, {method: 'GET', mode: 'cors'}) - - if (!res.ok) { - throw new Error(`Failed to fetch: ${res.status} ${res.statusText}`) - } - - const contentType = res.headers.get('content-type') || 'application/octet-stream' - const blob = await res.blob() - + const {blob, type, size} = await fetchWebImportBlob(url) const result = await handleFileAttachment(blob, draftId) return { displaySrc: result.displaySrc, fileBinary: result.fileBinary, - type: contentType, - size: blob.size, + type, + size, } } catch (err: any) { throw new Error(err?.message || 'Could not download file.') diff --git a/frontend/apps/web/app/document-edit/web-image-upload.test.ts b/frontend/apps/web/app/document-edit/web-image-upload.test.ts new file mode 100644 index 000000000..f62a12e3b --- /dev/null +++ b/frontend/apps/web/app/document-edit/web-image-upload.test.ts @@ -0,0 +1,19 @@ +import {afterEach, describe, expect, it, vi} from 'vitest' +import {fetchWebImportBlob} from './web-image-upload' + +describe('fetchWebImportBlob', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('downloads remote files through the same-origin proxy', async () => { + const fetchMock = vi.fn(async () => new Response(new Blob(['image'], {type: 'image/png'}))) + vi.stubGlobal('fetch', fetchMock) + + const result = await fetchWebImportBlob('https://example.com/image.png') + + expect(fetchMock).toHaveBeenCalledWith('/hm/api/web-file?url=https%3A%2F%2Fexample.com%2Fimage.png') + expect(result.type).toBe('image/png') + expect(result.size).toBe(5) + }) +}) diff --git a/frontend/apps/web/app/document-edit/web-image-upload.ts b/frontend/apps/web/app/document-edit/web-image-upload.ts index 928091efb..dfec59aa0 100644 --- a/frontend/apps/web/app/document-edit/web-image-upload.ts +++ b/frontend/apps/web/app/document-edit/web-image-upload.ts @@ -24,3 +24,47 @@ export function makeWebFileUpload(client: UniversalClient): (file: File) => Prom return resultCIDs[0] } } + +/** Download a remote web file through the same-origin web proxy. */ +export async function fetchWebImportBlob(url: string): Promise<{blob: Blob; type: string; size: number}> { + const res = await fetch(`/hm/api/web-file?url=${encodeURIComponent(url)}`) + + if (!res.ok) { + throw new Error(`Failed to fetch: ${res.status} ${res.statusText}`) + } + + const type = res.headers.get('content-type') || 'application/octet-stream' + const blob = await res.blob() + + return { + blob, + type, + size: blob.size, + } +} + +/** + * Build an importWebFile function for the web document editor. + * Remote bytes are fetched server-side to avoid browser CORS, then published to IPFS. + */ +export function makeWebImportWebFile( + client: UniversalClient, +): (url: string) => Promise<{cid: string; type: string; size: number}> { + const fileUpload = makeWebFileUpload(client) + return async (url: string) => { + const {blob, type, size} = await fetchWebImportBlob(url) + const file = new File([blob], filenameFromUrl(url), {type}) + const cid = await fileUpload(file) + return {cid, type, size} + } +} + +function filenameFromUrl(url: string) { + try { + const pathname = new URL(url).pathname + const name = pathname.split('/').filter(Boolean).pop() + return name || `imported-file-${Date.now()}` + } catch { + return `imported-file-${Date.now()}` + } +} diff --git a/frontend/apps/web/app/routes/hm.api.web-file.tsx b/frontend/apps/web/app/routes/hm.api.web-file.tsx new file mode 100644 index 000000000..1b6331f24 --- /dev/null +++ b/frontend/apps/web/app/routes/hm.api.web-file.tsx @@ -0,0 +1,105 @@ +import type {LoaderFunctionArgs} from '@remix-run/node' +import {MAX_FILE_SIZE_B, MAX_FILE_SIZE_MB} from '@shm/shared/constants' +import {lookup} from 'dns/promises' +import net from 'net' + +const MAX_REDIRECTS = 5 + +export async function loader({request}: LoaderFunctionArgs) { + const urlParam = new URL(request.url).searchParams.get('url') + if (!urlParam) return new Response('Missing url', {status: 400}) + + try { + const response = await fetchRemoteFile(urlParam) + const contentLength = Number(response.headers.get('content-length') || '0') + if (contentLength > MAX_FILE_SIZE_B) { + return new Response(`File too large, max size is ${MAX_FILE_SIZE_MB}MB`, {status: 413}) + } + + const buffer = await response.arrayBuffer() + if (buffer.byteLength > MAX_FILE_SIZE_B) { + return new Response(`File too large, max size is ${MAX_FILE_SIZE_MB}MB`, {status: 413}) + } + + return new Response(buffer, { + headers: { + 'Content-Type': response.headers.get('content-type') || 'application/octet-stream', + 'Content-Length': String(buffer.byteLength), + 'Cache-Control': 'no-store', + }, + }) + } catch (error) { + console.error('hm.api.web-file loader error:', error) + return new Response(error instanceof Error ? error.message : 'Failed to fetch file', {status: 400}) + } +} + +async function fetchRemoteFile(url: string, redirects = 0): Promise { + if (redirects > MAX_REDIRECTS) throw new Error('Too many redirects') + + const parsedUrl = new URL(url) + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + throw new Error('Only http and https URLs are supported') + } + await assertPublicHost(parsedUrl.hostname) + + const response = await fetch(parsedUrl, { + redirect: 'manual', + headers: { + 'User-Agent': 'Seed-Web-File-Import', + }, + }) + + if ([301, 302, 303, 307, 308].includes(response.status)) { + const location = response.headers.get('location') + if (!location) throw new Error('Redirect without location header') + return fetchRemoteFile(new URL(location, parsedUrl).toString(), redirects + 1) + } + + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`) + } + + return response +} + +async function assertPublicHost(hostname: string) { + const normalizedHostname = hostname.replace(/^\[|\]$/g, '') + const addresses = net.isIP(normalizedHostname) + ? [{address: normalizedHostname}] + : await lookup(normalizedHostname, { + all: true, + verbatim: true, + }) + + if (addresses.some(({address}) => isPrivateAddress(address))) { + throw new Error('URL host is not allowed') + } +} + +function isPrivateAddress(address: string) { + if (net.isIPv6(address)) { + const normalized = address.toLowerCase() + return ( + normalized === '::1' || + normalized.startsWith('fc') || + normalized.startsWith('fd') || + normalized.startsWith('fe80:') + ) + } + + const parts = address.split('.').map((part) => Number(part)) + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) return true + const first = parts[0]! + const second = parts[1]! + + return ( + first === 0 || + first === 10 || + first === 127 || + (first === 100 && second >= 64 && second <= 127) || + (first === 169 && second === 254) || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168) + ) +} diff --git a/frontend/apps/web/app/web-resource-page.tsx b/frontend/apps/web/app/web-resource-page.tsx index 256cf2f87..d7c5ac66d 100644 --- a/frontend/apps/web/app/web-resource-page.tsx +++ b/frontend/apps/web/app/web-resource-page.tsx @@ -40,7 +40,7 @@ import {createWebDocumentMachine} from './document-edit/web-document-actors' import {WebDraftActionsProvider} from './document-edit/web-draft-actions-provider' import {WebQueryBlockDraftSlot} from './document-edit/web-query-block-draft-slot' import {cleanupOldWebDocDrafts, getLatestWebDocDraftForDoc, getWebDocDraft} from './document-edit/web-draft-db' -import {makeWebFileUpload} from './document-edit/web-image-upload' +import {makeWebFileUpload, makeWebImportWebFile} from './document-edit/web-image-upload' import {getWebDraftPlaceholderId} from './document-edit/web-draft-path' /** Lazy-loaded inline comment editor — avoids pulling the full editor bundle eagerly. */ @@ -91,6 +91,7 @@ export function WebResourcePage({docId, CommentEditor, ssrContentHTML}: WebResou const linkExtensionOptions = useMemo(() => ({universalClient}), [universalClient]) const {canEdit, signingAccountId, capability} = useWebCanEdit(docId) const fileUpload = useMemo(() => makeWebFileUpload(universalClient), [universalClient]) + const importWebFile = useMemo(() => makeWebImportWebFile(universalClient), [universalClient]) // Editor accessor — populated by the editor's onEditorReady callback. const editorRef = useRef(null) @@ -380,6 +381,7 @@ export function WebResourcePage({docId, CommentEditor, ssrContentHTML}: WebResou draftVersionOnDiscardConfirm={webToolbarCallbacks.onDiscardConfirm} editingFloatingActions={editingFloatingActions} fileUpload={fileUpload} + importWebFile={importWebFile} /> diff --git a/frontend/packages/editor/src/comment-editor.tsx b/frontend/packages/editor/src/comment-editor.tsx index 2dbac84aa..326586a7a 100644 --- a/frontend/packages/editor/src/comment-editor.tsx +++ b/frontend/packages/editor/src/comment-editor.tsx @@ -15,6 +15,7 @@ import {Plugin, PluginKey} from '@tiptap/pm/state' import {type MutableRefObject, useEffect, useLayoutEffect, useRef, useState} from 'react' import avatarPlaceholder from './assets/avatar.png' import {BlockNoteEditor, getBlockInfoFromPos, useBlockNote} from './blocknote' +import type {HandleFileAttachmentFunction, ImportWebFileFunction} from './blocknote/core/BlockNoteEditor' import {insertOrUpdateBlock} from './blocknote/core/extensions/SlashMenu/defaultSlashMenuItems' import {FILE_DROP_INSERTED_EVENT} from './blocknote/core/extensions/DragMedia/DragExtension' import {HyperMediaEditorView} from './editor-view' @@ -52,23 +53,8 @@ export function useCommentEditor( onSubmit?: () => void, onMobileMentionTrigger?: () => void, onMobileSlashTrigger?: () => void, - importWebFile?: (url: string) => Promise<{ - displaySrc: string - fileBinary?: Uint8Array - type: string - size: number - }>, - handleFileAttachment?: (file: File) => Promise<{ - displaySrc: string - fileBinary?: Uint8Array - mediaRef?: { - draftId: string - mediaId: string - name: string - mime: string - size: number - } - }>, + importWebFile?: ImportWebFileFunction, + handleFileAttachment?: HandleFileAttachmentFunction, universalClient?: UniversalClient, // Resolver that maps a hostname (e.g. eric.vicenti.net) to its Seed account UID. // Required so URLs pasted into embed/link inputs inside a comment can be @@ -323,23 +309,8 @@ export function CommentEditor({ initialBlocks?: HMBlockNode[] onContentChange?: (blocks: HMBlockNode[], mediaRefs?: Record) => void onAvatarPress?: () => void - importWebFile?: (url: string) => Promise<{ - displaySrc: string - fileBinary?: Uint8Array - type: string - size: number - }> - handleFileAttachment?: (file: File) => Promise<{ - displaySrc: string - fileBinary?: Uint8Array - mediaRef?: { - draftId: string - mediaId: string - name: string - mime: string - size: number - } - }> + importWebFile?: ImportWebFileFunction + handleFileAttachment?: HandleFileAttachmentFunction getDraftMediaBlob?: (draftId: string, mediaId: string) => Promise /** Hide the leading avatar */ hideAvatar?: boolean diff --git a/frontend/packages/editor/src/handle-local-media-paste-plugin.test.ts b/frontend/packages/editor/src/handle-local-media-paste-plugin.test.ts index 788933882..ff59b1f73 100644 --- a/frontend/packages/editor/src/handle-local-media-paste-plugin.test.ts +++ b/frontend/packages/editor/src/handle-local-media-paste-plugin.test.ts @@ -3,6 +3,7 @@ import { createNodePropsFromAttachmentResult, extractPastedImageSources, imageSourceToFile, + shouldConvertPastedImageSourceToFile, } from './handle-local-media-paste-plugin' describe('local media paste helpers', () => { @@ -31,6 +32,14 @@ describe('local media paste helpers', () => { expect(extractPastedImageSources(html)).toEqual(['https://example.com/external.png']) }) + it('only converts local pasted HTML image sources to Files', () => { + expect(shouldConvertPastedImageSourceToFile('data:image/png;base64,abc')).toBe(true) + expect(shouldConvertPastedImageSourceToFile('blob:https://example.com/123')).toBe(true) + expect(shouldConvertPastedImageSourceToFile('https://example.com/image.webp')).toBe(false) + expect(shouldConvertPastedImageSourceToFile('http://example.com/image.webp')).toBe(false) + expect(shouldConvertPastedImageSourceToFile('ipfs://bafy...')).toBe(false) + }) + it('converts pasted HTML image sources to Files', async () => { const blob = new Blob(['jpeg data'], {type: 'image/jpeg'}) const fetchMock = vi.fn(async () => ({ diff --git a/frontend/packages/editor/src/handle-local-media-paste-plugin.ts b/frontend/packages/editor/src/handle-local-media-paste-plugin.ts index 827e4528f..014ec2ff5 100644 --- a/frontend/packages/editor/src/handle-local-media-paste-plugin.ts +++ b/frontend/packages/editor/src/handle-local-media-paste-plugin.ts @@ -65,9 +65,9 @@ const handleLocalMediaPastePlugin = (blockNoteEditor: any) => const html = event.clipboardData?.getData('text/html') || '' const htmlImageSources = extractPastedImageSources(html) - const firstHtmlImageSource = htmlImageSources[0] - if (firstHtmlImageSource) { - processImageSource(firstHtmlImageSource, view, insertPos, blockNoteEditor) + const firstConvertibleHtmlImageSource = htmlImageSources.find(shouldConvertPastedImageSourceToFile) + if (firstConvertibleHtmlImageSource) { + processImageSource(firstConvertibleHtmlImageSource, view, insertPos, blockNoteEditor) return true } @@ -117,6 +117,11 @@ export function extractPastedImageSources(html: string): string[] { .filter(Boolean) } +/** Whether a pasted HTML image src should be fetched in-renderer and converted into a File. */ +export function shouldConvertPastedImageSourceToFile(src: string): boolean { + return src.startsWith('data:') || src.startsWith('blob:') +} + /** Convert an image URL from pasted HTML into a File that can use the normal media insertion path. */ export async function imageSourceToFile(src: string, now: () => number = Date.now): Promise { const response = await fetch(src) diff --git a/frontend/packages/ui/src/resource-page-common.tsx b/frontend/packages/ui/src/resource-page-common.tsx index 3295936a8..2255f8cc1 100644 --- a/frontend/packages/ui/src/resource-page-common.tsx +++ b/frontend/packages/ui/src/resource-page-common.tsx @@ -347,6 +347,8 @@ export interface ResourcePageProps { publishAccountUid?: string /** Async function that uploads a File to the daemon and resolves to its CID. Platform-specific. */ fileUpload?: (file: File) => Promise + /** Imports a remote web file into platform storage. */ + importWebFile?: DocumentContentProps['importWebFile'] /** Account uid used in inline mention suggestions. */ perspectiveAccountUid?: string | null /** Options passed to the link extension. */ @@ -408,6 +410,7 @@ export function ResourcePage({ signingAccountId, publishAccountUid, fileUpload, + importWebFile, ssrContentHTML, perspectiveAccountUid, linkExtensionOptions, @@ -749,6 +752,7 @@ export function ResourcePage({ signingAccountId={signingAccountId} publishAccountUid={publishAccountUid} fileUpload={fileUpload} + importWebFile={importWebFile} ssrContentHTML={ssrContentHTML} perspectiveAccountUid={perspectiveAccountUid} linkExtensionOptions={linkExtensionOptions} @@ -955,6 +959,7 @@ function DocumentBody({ signingAccountId, publishAccountUid, fileUpload, + importWebFile, ssrContentHTML, perspectiveAccountUid, linkExtensionOptions, @@ -1000,6 +1005,8 @@ function DocumentBody({ publishAccountUid?: string /** Async function that uploads a File to the daemon and resolves to its CID */ fileUpload?: (file: File) => Promise + /** Imports a remote web file into platform storage. */ + importWebFile?: DocumentContentProps['importWebFile'] ssrContentHTML?: string | null /** Account uid used in inline mention suggestions. */ perspectiveAccountUid?: string | null @@ -1846,6 +1853,7 @@ function DocumentBody({ perspectiveAccountUid={perspectiveAccountUid} linkExtensionOptions={linkExtensionOptions} fileUpload={fileUpload} + importWebFile={importWebFile} draftVersionEntry={draftVersionEntry} /> @@ -2352,6 +2360,7 @@ function MainContent({ perspectiveAccountUid, linkExtensionOptions, fileUpload, + importWebFile, draftVersionEntry, }: { docId: UnpackedHypermediaId @@ -2401,6 +2410,7 @@ function MainContent({ perspectiveAccountUid?: string | null linkExtensionOptions?: LinkExtensionOptions fileUpload?: (file: File) => Promise + importWebFile?: DocumentContentProps['importWebFile'] draftVersionEntry?: DraftVersionEntry }) { const {originHomeId} = useUniversalAppContext() @@ -2507,6 +2517,7 @@ function MainContent({ perspectiveAccountUid={perspectiveAccountUid} linkExtensionOptions={linkExtensionOptions} fileUpload={fileUpload} + importWebFile={importWebFile} /> ) } @@ -2539,6 +2550,7 @@ function ContentViewWithOutline({ perspectiveAccountUid, linkExtensionOptions, fileUpload, + importWebFile, }: { docId: UnpackedHypermediaId resourceId: UnpackedHypermediaId @@ -2570,6 +2582,7 @@ function ContentViewWithOutline({ perspectiveAccountUid?: string | null linkExtensionOptions?: LinkExtensionOptions fileUpload?: (file: File) => Promise + importWebFile?: DocumentContentProps['importWebFile'] }) { const ctx = useDocumentSelector(selectContext) const rootChildrenType = (ctx.metadata?.childrenType ?? document.metadata?.childrenType) || 'Group' @@ -2633,6 +2646,7 @@ function ContentViewWithOutline({ linkExtensionOptions={linkExtensionOptions} isUnpublishedDraft={isUnpublishedDraft} isBlockInPublishedVersion={isBlockInPublishedVersion} + importWebFile={importWebFile} handleFileAttachment={handleFileAttachment} /> ) : ssrContentHTML ? (