Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions frontend/apps/desktop/src/components/commenting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -410,6 +411,7 @@ function CommentBoxImpl(props: {
handleSubmit={handleSubmit}
initialBlocks={draft.data?.blocks}
onContentChange={handleContentChange}
importWebFile={importWebFile}
handleFileAttachment={handleFileAttachment}
universalClient={universalClient}
domainResolver={domainResolver}
Expand Down Expand Up @@ -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<HMBlockNode[]>(comment.content)

const handleFileAttachment = useCallback(async (file: File) => {
Expand Down Expand Up @@ -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}
Expand Down
5 changes: 5 additions & 0 deletions frontend/apps/desktop/src/pages/agents/prompt-editor.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -18,6 +20,8 @@ export function AgentPromptEditor({
initialBlocks: HMBlockNode[]
onChange: (blocks: HMBlockNode[]) => void
}) {
const importWebFile = useCallback((url: string) => client.webImporting.importWebFile.mutate(url), [])

return (
<div className="border-input bg-background min-h-80 rounded-lg border p-3">
<CommentEditor
Expand All @@ -26,6 +30,7 @@ export function AgentPromptEditor({
initialBlocks={initialBlocks}
onContentChange={(blocks) => onChange(blocks)}
handleSubmit={() => {}}
importWebFile={importWebFile}
submitButton={() => <></>}
/>
</div>
Expand Down
3 changes: 3 additions & 0 deletions frontend/apps/desktop/src/pages/agents/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -666,6 +667,7 @@ function AgentRichMessageComposer({
}) {
const [draftMarkdown, setDraftMarkdown] = useState('')
const submitHandleRef = useRef<CommentEditorSubmitHandle | null>(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: []}))
Expand All @@ -691,6 +693,7 @@ function AgentRichMessageComposer({
initialBlocks={[]}
onContentChange={(blocks) => setDraftMarkdown(promptBlocksToMarkdown(trimTrailingEmptyBlocks(blocks)))}
handleSubmit={(getContent, reset) => void submitRichMessage(getContent, reset)}
importWebFile={importWebFile}
submitButton={() => <></>}
/>
</div>
Expand Down
15 changes: 4 additions & 11 deletions frontend/apps/web/app/commenting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.')
Expand Down
19 changes: 19 additions & 0 deletions frontend/apps/web/app/document-edit/web-image-upload.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
44 changes: 44 additions & 0 deletions frontend/apps/web/app/document-edit/web-image-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()}`
}
}
105 changes: 105 additions & 0 deletions frontend/apps/web/app/routes/hm.api.web-file.tsx
Original file line number Diff line number Diff line change
@@ -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<Response> {
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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is kind of a red flag, indicates a larger security issue with this approach. Probably we should not have this web-file interface at all. The client should request the file and upload it normally.

Is this intended as a sort of CORS bypass?

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)
)
}
4 changes: 3 additions & 1 deletion frontend/apps/web/app/web-resource-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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<any>(null)
Expand Down Expand Up @@ -380,6 +381,7 @@ export function WebResourcePage({docId, CommentEditor, ssrContentHTML}: WebResou
draftVersionOnDiscardConfirm={webToolbarCallbacks.onDiscardConfirm}
editingFloatingActions={editingFloatingActions}
fileUpload={fileUpload}
importWebFile={importWebFile}
/>
</QueryBlockDraftsProvider>
</WebDraftActionsProvider>
Expand Down
39 changes: 5 additions & 34 deletions frontend/packages/editor/src/comment-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -323,23 +309,8 @@ export function CommentEditor({
initialBlocks?: HMBlockNode[]
onContentChange?: (blocks: HMBlockNode[], mediaRefs?: Record<string, string>) => 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<Blob | null>
/** Hide the leading avatar */
hideAvatar?: boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
createNodePropsFromAttachmentResult,
extractPastedImageSources,
imageSourceToFile,
shouldConvertPastedImageSourceToFile,
} from './handle-local-media-paste-plugin'

describe('local media paste helpers', () => {
Expand Down Expand Up @@ -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 () => ({
Expand Down
Loading
Loading