Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import {
extractImgSrcs,
findHostedImageAttrs,
hasHostedImageHtml,
htmlReferencesSrc,
isInlineRouteSrc,
shouldSkipFileUpload,
toSameOriginPath,
} from './image-paste'

// jsdom lacks `elementFromPoint`; the Placeholder extension's viewport tracking calls it on mount.
Expand Down Expand Up @@ -288,3 +290,97 @@ describe('findHostedImageAttrs', () => {
expect(originalAlt).toBe('photo')
})
})

describe('origin-aware src normalization (browser-native drag/copy enrichment uses ABSOLUTE urls)', () => {
const ORIGIN = 'https://www.staging.sim.ai'

it('toSameOriginPath: relative passes through; same-origin absolute → path+query; cross-origin → null', () => {
expect(toSameOriginPath('/api/files/view/wf_a', ORIGIN)).toBe('/api/files/view/wf_a')
expect(toSameOriginPath(`${ORIGIN}/api/workspaces/ws-1/files/inline?fileId=wf_a`, ORIGIN)).toBe(
'/api/workspaces/ws-1/files/inline?fileId=wf_a'
)
expect(toSameOriginPath('https://evil.example.com/api/files/view/wf_a', ORIGIN)).toBeNull()
})

it('isInlineRouteSrc accepts the same-origin ABSOLUTE inline route (the real dragged-img src on staging)', () => {
expect(isInlineRouteSrc(`${ORIGIN}/api/workspaces/ws-1/files/inline?fileId=wf_a`, ORIGIN)).toBe(
true
)
expect(
isInlineRouteSrc(
'https://evil.example.com/api/workspaces/ws-1/files/inline?fileId=wf_a',
ORIGIN
)
).toBe(false)
})

it('hasHostedImageHtml matches absolute same-origin srcs and rejects cross-origin ones', () => {
const isHosted = (src: string) => extractEmbeddedFileRef(src) !== null
expect(hasHostedImageHtml(`<img src="${ORIGIN}/api/files/view/wf_a">`, isHosted, ORIGIN)).toBe(
true
)
expect(
hasHostedImageHtml(
`<img src="${ORIGIN}/api/workspaces/ws-1/files/inline?fileId=wf_a">`,
isHosted,
ORIGIN
)
).toBe(true)
expect(
hasHostedImageHtml(
'<img src="https://evil.example.com/api/files/view/wf_a">',
isHosted,
ORIGIN
)
).toBe(false)
})

it('findHostedImageAttrs matches when the clipboard html carries the absolute rendered url', () => {
const ws = createWorkspaceFileContentSource('ws-1')
const editor = new Editor({
extensions: createMarkdownEditorExtensions({ placeholder: '' }),
content: {
type: 'doc',
content: [{ type: 'image', attrs: { src: '/api/files/view/wf_abc', alt: 'photo' } }],
},
})
const absolute = `${ORIGIN}/api/workspaces/ws-1/files/inline?fileId=wf_abc`
const match = findHostedImageAttrs(editor.state.doc, [absolute], ws.resolveImageSrc, ORIGIN)
expect(match?.src).toBe('/api/files/view/wf_abc')
})
})

describe('htmlReferencesSrc (the "this drop is MY dragged image" check)', () => {
const ORIGIN = 'https://www.staging.sim.ai'
const RESOLVED = '/api/workspaces/ws-1/files/inline?fileId=wf_a'

it('true when the html img src is the absolute form of the resolved src', () => {
expect(htmlReferencesSrc(`<img src="${ORIGIN}${RESOLVED}">`, RESOLVED, ORIGIN)).toBe(true)
})

it('true for the relative form too (ProseMirror-serialized html)', () => {
expect(htmlReferencesSrc(`<img src="${RESOLVED}">`, RESOLVED, ORIGIN)).toBe(true)
})

// Identity must work for cross-origin srcs too: a doc's image may legitimately point at a CDN or
// badge URL, and dragging THAT node to reorder it must still match — under the previous
// same-origin-path comparison it fell into the duplicate-upload branch instead.
it('matches an external (cross-origin) image against its own exact absolute url', () => {
const EXTERNAL = 'https://cdn.example.com/badge.svg'
expect(htmlReferencesSrc(`<img src="${EXTERNAL}">`, EXTERNAL, ORIGIN)).toBe(true)
expect(
htmlReferencesSrc('<img src="https://cdn.example.com/other.svg">', EXTERNAL, ORIGIN)
).toBe(false)
})

it('false for a different image, empty html, missing resolved src, or cross-origin', () => {
expect(htmlReferencesSrc(`<img src="${ORIGIN}/api/other?fileId=wf_b">`, RESOLVED, ORIGIN)).toBe(
false
)
expect(htmlReferencesSrc('', RESOLVED, ORIGIN)).toBe(false)
expect(htmlReferencesSrc(`<img src="${ORIGIN}${RESOLVED}">`, undefined, ORIGIN)).toBe(false)
expect(
htmlReferencesSrc(`<img src="https://evil.example.com${RESOLVED}">`, RESOLVED, ORIGIN)
).toBe(false)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -13,27 +13,63 @@ export function extractImageFiles(transfer: DataTransfer | null): File[] {
.filter((file): file is File => file !== null)
}

// `src` may be double-quoted, single-quoted, or (validly) unquoted per the HTML spec — the browser's
// own clipboard serialization always quotes it, but other producers of `text/html` are not obligated
// to.
/**
* Matches `<img>` `src` attribute values: double-quoted, single-quoted, or (validly) unquoted per
* the HTML spec — the browser's own clipboard serialization always quotes it, but other producers
* of `text/html` are not obligated to.
*/
const IMG_SRC_RE = /<img\b[^>]*\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/gi

/** Query params under which the inline route addresses a workspace file. */
const INLINE_ROUTE_QUERY_KEYS = new Set(['key', 'fileId'])

/**
* The page's own origin — clipboard/dataTransfer srcs on any other origin are never ours.
* Deliberately `window.location.origin`, NOT `getBaseUrl()`: the browser serializes a dragged/copied
* `<img>`'s URL against the origin the page is ACTUALLY being viewed on, which can legitimately
* differ from the configured `NEXT_PUBLIC_APP_URL` (localhost dev against a shared env, preview
* deploys, apex-vs-www) — comparing against the configured URL would silently fail on any such
* origin, which is the exact bug class this normalization exists to fix.
*/
function runtimeOrigin(): string {
return typeof window === 'undefined' ? '' : window.location.origin
}

/**
* Normalizes a clipboard/dataTransfer img `src` to an origin-relative `pathname?query`, or `null`
* when it belongs to a different origin. The browser's NATIVE drag/copy enrichment (dragging or
* "Copy Image" on a rendered `<img>`) serializes the ABSOLUTE resolved URL —
* `https://host/api/workspaces/…/inline?…` — while everything the app compares against (persisted
* refs, `resolveImageSrc` output) is origin-relative, so both must be brought into the same space
* before comparing. A cross-origin src must never be treated as ours.
*/
export function toSameOriginPath(src: string, origin = runtimeOrigin()): string | null {
try {
const base = origin || 'http://placeholder'
const parsed = new URL(src, base)
if (parsed.origin !== base) return null
return parsed.pathname + parsed.search
} catch {
return null
}
}

/**
* True for the *display-layer* inline route `resolveImageSrc` (see `use-file-content-source.tsx`)
* rewrites an embed to — workspace-scoped `/api/workspaces/{workspaceId}/files/inline?key=…`/`?fileId=…`
* or public-share-scoped `/api/files/public/{token}/inline?key=…`/`?fileId=…`. This is the shape
* actually rendered into `<img src>`, and so what a same-page copy's `text/html` clipboard payload
* actually contains NOT the raw stored reference `extractEmbeddedFileRef` (in
* `@/lib/uploads/utils/embedded-image-ref`) recognizes, which only matches the persisted `src` before
* that rewrite. Checked separately from (rather than folded into) `extractEmbeddedFileRef` since that
* helper is shared with server-side authorization/export code operating on persisted content, where
* this display-only shape should never legitimately appear.
* actually contains (absolute — see {@link toSameOriginPath}) — NOT the raw stored reference
* `extractEmbeddedFileRef` (in `@/lib/uploads/utils/embedded-image-ref`) recognizes, which only
* matches the persisted `src` before that rewrite. Checked separately from (rather than folded into)
* `extractEmbeddedFileRef` since that helper is shared with server-side authorization/export code
* operating on persisted content, where this display-only shape should never legitimately appear.
*/
export function isInlineRouteSrc(src: string): boolean {
export function isInlineRouteSrc(src: string, origin = runtimeOrigin()): boolean {
const path = toSameOriginPath(src, origin)
if (path === null) return false
try {
const parsed = new URL(src, 'http://placeholder')
if (parsed.origin !== 'http://placeholder') return false
const parsed = new URL(path, 'http://placeholder')
if (!parsed.pathname.endsWith('/inline')) return false
for (const key of parsed.searchParams.keys()) {
if (INLINE_ROUTE_QUERY_KEYS.has(key)) return true
Expand Down Expand Up @@ -62,9 +98,53 @@ export function extractImgSrcs(html: string): string[] {
* select it) makes the browser put BOTH `text/html` (the real serialized node, with its real hosted
* `src`) AND a synthesized image `File` onto the clipboard — the same "drag a web image out" behavior
* that {@link extractImageFiles} alone can't tell apart from a genuinely new external image paste.
* Srcs are normalized origin-relative first ({@link toSameOriginPath}): ProseMirror's own clipboard
* serialization writes the persisted relative src, but the BROWSER's native enrichment writes the
* absolute resolved URL.
*/
export function hasHostedImageHtml(html: string, isHostedRef: (src: string) => boolean): boolean {
return extractImgSrcs(html).some((src) => isHostedRef(src) || isInlineRouteSrc(src))
export function hasHostedImageHtml(
html: string,
isHostedRef: (src: string) => boolean,
origin = runtimeOrigin()
): boolean {
return extractImgSrcs(html).some((src) => {
const path = toSameOriginPath(src, origin)
return path !== null && (isHostedRef(path) || isInlineRouteSrc(path, origin))
})
}

/** Resolves `src` to a full absolute URL against `origin`, or `null` when unparseable. */
function toAbsoluteUrl(src: string, origin: string): string | null {
try {
return new URL(src, origin || 'http://placeholder').href
} catch {
return null
}
}

/**
* True when `html` contains an `<img>` whose src resolves to the same ABSOLUTE URL as
* `resolvedSrc` (a `resolveImageSrc` output for a node already in this document). This is the
* "that drop is MY dragged image" check for internal drag-reorder: TipTap's node-view dragstart
* bypasses ProseMirror's serialization entirely (no PM `text/html`, no `view.dragging`) but
* NodeSelects the dragged image, and the browser's native enrichment carries the absolute rendered
* URL of exactly that node.
*
* Deliberately compares full absolute URLs rather than same-origin paths ({@link toSameOriginPath}):
* identity is the question here, not hosted-by-us membership — a doc's image may legitimately have a
* cross-origin `src` (a README badge, a CDN image), and dragging THAT node to reorder it must match
* too, or it falls into the duplicate-upload path. Relative and absolute spellings of the same URL
* still compare equal because both sides resolve against the page origin.
*/
export function htmlReferencesSrc(
html: string,
resolvedSrc: string | undefined,
origin = runtimeOrigin()
): boolean {
if (!html || !resolvedSrc) return false
const target = toAbsoluteUrl(resolvedSrc, origin)
if (target === null) return false
return extractImgSrcs(html).some((src) => toAbsoluteUrl(src, origin) === target)
}

/**
Expand Down Expand Up @@ -108,20 +188,26 @@ interface DescendantsDoc {
* the clipboard/dataTransfer `html`, whose `src` is `resolveImageSrc`'s rewritten *display* URL, not the
* real persisted one. Inserting a node built from that display URL would bake it into the document,
* which public share/export/referenced-by-doc tracking don't recognize (they only match the persisted
* shape) — this lookup avoids ever constructing such a node in the first place.
* shape) — this lookup avoids ever constructing such a node in the first place. Both sides of the
* comparison are normalized origin-relative ({@link toSameOriginPath}): the clipboard html may carry
* the browser's absolute URLs.
*/
export function findHostedImageAttrs(
doc: DescendantsDoc,
targetSrcs: string[],
resolveImageSrc: (src: string | undefined) => string | undefined
resolveImageSrc: (src: string | undefined) => string | undefined,
origin = runtimeOrigin()
): Record<string, unknown> | null {
const targets = new Set(targetSrcs)
const targets = new Set(
targetSrcs.map((src) => toSameOriginPath(src, origin)).filter((p): p is string => p !== null)
)
let found: Record<string, unknown> | null = null
doc.descendants((node) => {
if (found) return false
if (node.type.name === 'image') {
const resolved = resolveImageSrc(node.attrs.src as string | undefined)
if (resolved && targets.has(resolved)) {
const resolvedPath = resolved ? toSameOriginPath(resolved, origin) : null
if (resolvedPath && targets.has(resolvedPath)) {
found = { ...node.attrs }
return false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
import { memo, useEffect, useRef, useState } from 'react'
import { cn, toast } from '@sim/emcn'
import type { JSONContent } from '@tiptap/core'
import { Fragment, Slice } from '@tiptap/pm/model'
import { NodeSelection } from '@tiptap/pm/state'
import { dropPoint } from '@tiptap/pm/transform'
import type { Editor } from '@tiptap/react'
import { EditorContent, useEditor } from '@tiptap/react'
import { useRouter } from 'next/navigation'
Expand All @@ -19,6 +22,7 @@ import {
extractImageFiles,
extractImgSrcs,
findHostedImageAttrs,
htmlReferencesSrc,
shouldSkipFileUpload,
} from './image-paste'
import {
Expand Down Expand Up @@ -216,6 +220,8 @@ export function LoadedRichMarkdownEditor({
const uploadFile = useUploadWorkspaceFile()
const editorInstanceRef = useRef<Editor | null>(null)
const source = useFileContentSource()
const resolveImageSrcRef = useRef(source.resolveImageSrc)
resolveImageSrcRef.current = source.resolveImageSrc

/**
* The `/Image` slash command opens this hidden picker; `pendingImagePosRef` holds the caret position
Expand Down Expand Up @@ -367,22 +373,58 @@ export function LoadedRichMarkdownEditor({
* the browser doesn't navigate away from the editor; internal text drags carry no files and fall
* through to the default behavior.
*
* Dragging an existing image node to reorder it is also an internal drag, but the browser's
* native drag-and-drop synthesizes an image `File` into `event.dataTransfer` for a dragged `<img>`
* (the same mechanism that lets a user drag a web image out to their desktop) — indistinguishable
* from a real external drop by `dataTransfer` files/items alone. When the accompanying `text/html`
* shows it's a same-page drag of an already-hosted image, bail out and let ProseMirror's own
* default move logic run — it relocates the actual existing node object (`dragging.node`), never
* re-parsing html, so (unlike the paste case above) there's no display-vs-persisted src risk here.
* Checked from `html`, not `view.dragging`, so a stale `dragging` flag (ProseMirror clears it up
* to ~50ms late via `dragend` when a prior internal drag was dropped outside this view) can never
* suppress the plain-file swallow guard below for an unrelated drop that happens to land in that
* window — this only reacts to what THIS specific drop's `html` actually contains.
* Drag-REORDER of an image node is the deceptive case. TipTap's node-view dragstart bypasses
* ProseMirror's own drag serialization entirely — no PM `text/html`, no `view.dragging` — but it
* DOES NodeSelect the dragged image; what the drop carries instead is the browser's native
* enrichment for a dragged `<img>`: an image `File` plus `text/html` whose src is the ABSOLUTE
* rendered URL of that exact node. So when the drop's html points at the currently-selected image
* node ({@link htmlReferencesSrc}), this drop IS that node being moved, and the move must be
* performed here: uploading would duplicate it (the original never moves), and falling through to
* ProseMirror is no better — with `view.dragging` unset its default drop PARSES the html into a
* copy (persisting the display-layer src, which share/export tracking don't recognize) and never
* deletes the original. The gate accepts at most one file (not exactly one): some drag transports
* (e.g. CDP-driven input) carry the html alone, and a genuinely external drop can never reference
* the currently-selected node's own resolved src.
*
* The move itself is the same shape as ProseMirror's own: compute the drop point on the
* pre-delete doc, delete the source, map the insert position through that delete. A null
* `dropPoint` (no valid insertion point) is a handled no-op — the node stays put, still
* selected — never a raw-position fallback, which `tr.insert` could throw on (PM's own null
* fallback is only safe because it uses the forgiving `replaceRangeWith`).
*
* PM-serialized drags (a text selection spanning an image, dragged from a textblock) still reach
* the `shouldSkipFileUpload` bail below: PM set `view.dragging` for those itself, so its default
* move logic is correct there.
*/
handleDrop: (view, event) => {
if (!view.editable) return false
const images = extractImageFiles(event.dataTransfer)
const html = event.dataTransfer?.getData('text/html') ?? ''
const { selection } = view.state
if (
images.length <= 1 &&
selection instanceof NodeSelection &&
selection.node.type.name === 'image' &&
htmlReferencesSrc(html, resolveImageSrcRef.current(selection.node.attrs.src))
Comment thread
waleedlatif1 marked this conversation as resolved.
) {
event.preventDefault()
const coords = view.posAtCoords({ left: event.clientX, top: event.clientY })
if (!coords) return true
const node = selection.node
const tr = view.state.tr
const insertPos = dropPoint(
view.state.doc,
coords.pos,
new Slice(Fragment.from(node), 0, 0)
)
if (insertPos === null) return true
tr.delete(selection.from, selection.to)
const mapped = tr.mapping.map(insertPos)
tr.insert(mapped, node)
tr.setSelection(NodeSelection.create(tr.doc, mapped))
view.dispatch(tr.scrollIntoView())
return true
}
if (shouldSkipFileUpload(images, html, (src) => extractEmbeddedFileRef(src) !== null)) {
return false
}
Expand Down
Loading