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
99 changes: 99 additions & 0 deletions packages/presentation/src/___tests___/file-blob.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
// Copyright © 2026 Exaviz.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//

import { setMetadata } from '@hcengineering/platform'

import { authorizationHeaders, getFileBlobUrl, isLocalObjectUrl } from '../file-blob'
import plugin from '../plugin'

describe('isLocalObjectUrl', () => {
it('accepts blob and data URLs', () => {
expect(isLocalObjectUrl('blob:https://huly.app/abc')).toBe(true)
expect(isLocalObjectUrl('data:application/pdf;base64,AAA')).toBe(true)
})

it('rejects remote and relative URLs', () => {
expect(isLocalObjectUrl('https://dl.huly.app/blob/ws/file')).toBe(false)
expect(isLocalObjectUrl('/files/ws/file')).toBe(false)
expect(isLocalObjectUrl('')).toBe(false)
})
})

describe('authorizationHeaders', () => {
it('omits Authorization when the token is empty', () => {
expect(authorizationHeaders(undefined)).toEqual({})
expect(authorizationHeaders('')).toEqual({})
})

it('sends a Bearer token', () => {
expect(authorizationHeaders('abc.def')).toEqual({ Authorization: 'Bearer abc.def' })
})
})

describe('getFileBlobUrl', () => {
const originalFetch = globalThis.fetch
// eslint-disable-next-line @typescript-eslint/unbound-method
const originalCreate = URL.createObjectURL
const createObjectUrl = jest.fn(() => 'blob:https://huly.app/generated')

beforeEach(() => {
globalThis.fetch = jest.fn()
createObjectUrl.mockClear()
URL.createObjectURL = createObjectUrl
setMetadata(plugin.metadata.Token, 'tok')
})

afterEach(() => {
globalThis.fetch = originalFetch
URL.createObjectURL = originalCreate
setMetadata(plugin.metadata.Token, undefined)
})

it('returns local object URLs without fetching', async () => {
const src = 'blob:https://huly.app/existing'
await expect(getFileBlobUrl(src)).resolves.toEqual({ url: src, owned: false })
expect(globalThis.fetch).not.toHaveBeenCalled()
})

it('fetches remote files with the session token and wraps the body in a blob URL', async () => {
const body = new Blob(['%PDF-1.4'], { type: 'application/pdf' })
;(globalThis.fetch as jest.Mock).mockResolvedValue({ ok: true, blob: async () => body })

await expect(getFileBlobUrl('https://dl.huly.app/blob/ws/file')).resolves.toEqual({
url: 'blob:https://huly.app/generated',
owned: true
})
expect(globalThis.fetch).toHaveBeenCalledWith('https://dl.huly.app/blob/ws/file', {
headers: { Authorization: 'Bearer tok' },
signal: undefined
})
expect(createObjectUrl).toHaveBeenCalledWith(body)
})

it('sends no Authorization header when there is no session token', async () => {
setMetadata(plugin.metadata.Token, undefined)
;(globalThis.fetch as jest.Mock).mockResolvedValue({ ok: true, blob: async () => new Blob(['x']) })

await getFileBlobUrl('/files/ws/file')
expect(globalThis.fetch).toHaveBeenCalledWith('/files/ws/file', { headers: {}, signal: undefined })
})

it('throws when the file server rejects the request', async () => {
;(globalThis.fetch as jest.Mock).mockResolvedValue({ ok: false, status: 401 })
await expect(getFileBlobUrl('https://dl.huly.app/blob/ws/file')).rejects.toThrow('Failed to fetch file: 401')
expect(createObjectUrl).not.toHaveBeenCalled()
})
})
5 changes: 3 additions & 2 deletions packages/presentation/src/components/PDFViewer.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
// Copyright © 2026 Exaviz.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
Expand All @@ -17,7 +18,7 @@
import type { Blob, Ref } from '@hcengineering/core'
import { Button, Dialog, EmbeddedPDF, Label, Spinner } from '@hcengineering/ui'
import { createEventDispatcher, onMount } from 'svelte'
import presentation, { getFileUrl } from '..'
import presentation, { getFileBlobUrl, getFileUrl } from '..'
import ActionContext from './ActionContext.svelte'
import Download from './icons/Download.svelte'

Expand Down Expand Up @@ -97,7 +98,7 @@
<img class="img-fit" {src} alt="" />
</div>
{:else}
<EmbeddedPDF {src} {name} {css} fit />
<EmbeddedPDF {src} {name} {css} resolveUrl={getFileBlobUrl} fit />
{/if}
{:else}
<div class="centered">
Expand Down
64 changes: 64 additions & 0 deletions packages/presentation/src/file-blob.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
// Copyright © 2026 Exaviz.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//

import { getMetadata } from '@hcengineering/platform'

import plugin from './plugin'

/**
* blob: and data: URLs are already same-origin object URLs. Embedding them
* in an iframe does not need another download, and the caller owns revoke.
*/
export function isLocalObjectUrl (src: string): boolean {
return src.startsWith('blob:') || src.startsWith('data:')
}

/**
* Non-credentialed Authorization header. Datalake accepts Bearer tokens this
* way; default cors() allows the preflight. Do not use credentials:'include'
* — the file server answers Access-Control-Allow-Origin: * without
* Allow-Credentials, so a cookie-bearing cross-origin fetch is blocked.
*/
export function authorizationHeaders (token: string | undefined): HeadersInit {
if (token === undefined || token === '') {
return {}
}
return { Authorization: `Bearer ${token}` }
}

/**
* Download a file with the current session token and return a blob: URL
* suitable for iframe embedding. Local object URLs are returned as-is.
* Needed because an iframe cannot send an Authorization header, and cookies
* do not reach a cross-origin file server (desktop app, split-host deployments).
* @public
*/
export async function getFileBlobUrl (src: string, signal?: AbortSignal): Promise<{ url: string, owned: boolean }> {
if (isLocalObjectUrl(src)) {
return { url: src, owned: false }
}

const response = await fetch(src, {
headers: authorizationHeaders(getMetadata(plugin.metadata.Token)),
signal
})
if (!response.ok) {
throw new Error(`Failed to fetch file: ${response.status}`)
}

const blob = await response.blob()
return { url: URL.createObjectURL(blob), owned: true }
}
1 change: 1 addition & 0 deletions packages/presentation/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export { default } from './plugin'
export type * from './types'
export * from './utils'
export * from './file'
export * from './file-blob'
export * from './filetypes'
export * from './drafts'
export { presentationId }
Expand Down
33 changes: 5 additions & 28 deletions packages/ui/src/components/EmbeddedHTML.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<!--
// Copyright © 2026 Hardcore Engineering Inc.
// Copyright © 2026 Exaviz.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
Expand All @@ -14,39 +15,15 @@
-->

<script lang="ts">
import { onDestroy } from 'svelte'
import type { EmbeddableUrlResolver } from '../file-embed-utils'
import EmbeddedPDF from './EmbeddedPDF.svelte'
import Loading from './Loading.svelte'

export let src: string
export let name: string
export let fit: boolean = false
export let css: string | undefined = undefined

let iframeSrc: string | undefined

async function loadFile(src: string): Promise<void> {
if (iframeSrc !== undefined) {
URL.revokeObjectURL(iframeSrc)
iframeSrc = undefined
}

const response = await fetch(src)
const blob = await response.blob()
iframeSrc = URL.createObjectURL(blob)
}

$: void loadFile(src)

onDestroy(() => {
if (iframeSrc !== undefined) {
URL.revokeObjectURL(iframeSrc)
}
})
export let resolveUrl: EmbeddableUrlResolver | undefined = undefined
</script>

{#if iframeSrc}
<EmbeddedPDF src={iframeSrc} {name} {fit} {css} />
{:else}
<Loading />
{/if}
<!-- css is injected into the iframe document by EmbeddedPDF (DOCX-to-HTML preview). -->
<EmbeddedPDF {src} {name} {fit} {css} {resolveUrl} />
58 changes: 57 additions & 1 deletion packages/ui/src/components/EmbeddedPDF.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
// Copyright © 2026 Exaviz.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
Expand All @@ -14,12 +15,63 @@
-->

<script lang="ts">
import { onDestroy } from 'svelte'
import type { EmbeddableUrlResolver } from '../file-embed-utils'
import Loading from './Loading.svelte'

export let src: string
export let name: string
export let fit: boolean = false
export let css: string | undefined = undefined
export let resolveUrl: EmbeddableUrlResolver | undefined = undefined

let iframe: HTMLIFrameElement | undefined = undefined
let iframeSrc: string | undefined
let owned = false
let failed = false
let controller: AbortController | undefined

function revokeOwned(): void {
if (owned && iframeSrc !== undefined) {
URL.revokeObjectURL(iframeSrc)
}
iframeSrc = undefined
owned = false
}

async function loadFile(src: string, resolveUrl?: EmbeddableUrlResolver): Promise<void> {
controller?.abort()
controller = new AbortController()
const { signal } = controller

failed = false
revokeOwned()

try {
const result = (await resolveUrl?.(src, signal)) ?? { url: src, owned: false }
if (signal.aborted) {
if (result.owned) {
URL.revokeObjectURL(result.url)
}
return
}
iframeSrc = result.url
owned = result.owned
} catch (err: any) {
if (err?.name === 'AbortError') {
return
}
failed = true
console.error('Failed to load embedded file', err)
}
}

$: void loadFile(src, resolveUrl)

onDestroy(() => {
controller?.abort()
revokeOwned()
})

// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
$: if (css !== undefined && iframe !== undefined && iframe !== null) {
Expand All @@ -42,7 +94,11 @@
}
</script>

<iframe bind:this={iframe} class:fit src={src + '#view=FitH&navpanes=0'} title={name} on:load />
{#if iframeSrc}
<iframe bind:this={iframe} class:fit src={iframeSrc + '#view=FitH&navpanes=0'} title={name} on:load />
{:else if !failed}
<Loading />
{/if}

<style lang="scss">
iframe {
Expand Down
30 changes: 30 additions & 0 deletions packages/ui/src/file-embed-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
// Copyright © 2026 Exaviz.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//

/**
* A URL that can be embedded in an iframe. When `owned` is true the URL is a
* blob: URL created for the embed, and the embedding component revokes it.
*/
export interface EmbeddableUrl {
url: string
owned: boolean
}

/**
* Turns a file source into an embeddable URL. The caller (presentation layer)
* supplies it, so the UI components never handle authentication.
*/
export type EmbeddableUrlResolver = (src: string, signal?: AbortSignal) => Promise<EmbeddableUrl>
7 changes: 4 additions & 3 deletions plugins/print-resources/src/components/DOCXViewer.svelte
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
// Copyright © 2026 Exaviz.
//
-->

<script lang="ts">
import { Analytics } from '@hcengineering/analytics'
import { type Blob, type BlobMetadata, type Ref } from '@hcengineering/core'
import { getMetadata } from '@hcengineering/platform'
import presentation, { getFileUrl } from '@hcengineering/presentation'
import presentation, { getFileBlobUrl, getFileUrl } from '@hcengineering/presentation'
import print, { convertForPreview, type ConvertedPreview } from '@hcengineering/print'
import { Button, EmbeddedHTML, EmbeddedPDF, Label, Spinner, themeStore } from '@hcengineering/ui'
import { onDestroy } from 'svelte'
Expand Down Expand Up @@ -306,9 +307,9 @@
{:else if src}
{#key src}
{#if convertedFile?.contentType === 'application/pdf'}
<EmbeddedPDF {src} name={previewName} />
<EmbeddedPDF {src} name={previewName} resolveUrl={getFileBlobUrl} />
{:else}
<EmbeddedHTML {src} {name} {css} />
<EmbeddedHTML {src} {name} {css} resolveUrl={getFileBlobUrl} />
{/if}
{/key}
{/if}
Expand Down
Loading
Loading