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
5 changes: 4 additions & 1 deletion packages/presentation/src/components/PDFViewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<script lang="ts">
// import { Doc } from '@hcengineering/core'
import type { Blob, Ref } from '@hcengineering/core'
import { getMetadata } from '@hcengineering/platform'
import { Button, Dialog, EmbeddedPDF, Label, Spinner } from '@hcengineering/ui'
import { createEventDispatcher, onMount } from 'svelte'
import presentation, { getFileUrl } from '..'
Expand Down Expand Up @@ -45,6 +46,8 @@
})
let download: HTMLAnchorElement

const token = getMetadata(presentation.metadata.Token)

$: src = file !== undefined ? getFileUrl(file, name) : undefined

$: isImage = contentType !== undefined && contentType.startsWith('image/')
Expand Down Expand Up @@ -97,7 +100,7 @@
<img class="img-fit" {src} alt="" />
</div>
{:else}
<EmbeddedPDF {src} {name} {css} fit />
<EmbeddedPDF {src} {name} {css} {token} fit />
{/if}
{:else}
<div class="centered">
Expand Down
100 changes: 100 additions & 0 deletions packages/ui/src/__test__/file-embed-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// 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 { authorizationHeaders, fetchAsObjectUrl, isLocalObjectUrl } from '../file-embed-utils'

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('fetchAsObjectUrl', () => {
const originalFetch = global.fetch

Check failure on line 43 in packages/ui/src/__test__/file-embed-utils.test.ts

View workflow job for this annotation

GitHub Actions / build

Cannot find name 'global'.

Check failure on line 43 in packages/ui/src/__test__/file-embed-utils.test.ts

View workflow job for this annotation

GitHub Actions / build

Cannot find name 'global'.

Check failure on line 43 in packages/ui/src/__test__/file-embed-utils.test.ts

View workflow job for this annotation

GitHub Actions / uitest-workspaces

Cannot find name 'global'.

Check failure on line 43 in packages/ui/src/__test__/file-embed-utils.test.ts

View workflow job for this annotation

GitHub Actions / uitest-workspaces

Cannot find name 'global'.
const originalCreate = URL.createObjectURL

beforeEach(() => {
global.fetch = jest.fn()

Check failure on line 47 in packages/ui/src/__test__/file-embed-utils.test.ts

View workflow job for this annotation

GitHub Actions / build

Cannot find name 'global'.

Check failure on line 47 in packages/ui/src/__test__/file-embed-utils.test.ts

View workflow job for this annotation

GitHub Actions / uitest-workspaces

Cannot find name 'global'.
URL.createObjectURL = jest.fn(() => 'blob:https://huly.app/generated')
})

afterEach(() => {
global.fetch = originalFetch

Check failure on line 52 in packages/ui/src/__test__/file-embed-utils.test.ts

View workflow job for this annotation

GitHub Actions / build

Cannot find name 'global'.

Check failure on line 52 in packages/ui/src/__test__/file-embed-utils.test.ts

View workflow job for this annotation

GitHub Actions / uitest-workspaces

Cannot find name 'global'.
URL.createObjectURL = originalCreate
})

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

Check failure on line 59 in packages/ui/src/__test__/file-embed-utils.test.ts

View workflow job for this annotation

GitHub Actions / build

Cannot find name 'global'.

Check failure on line 59 in packages/ui/src/__test__/file-embed-utils.test.ts

View workflow job for this annotation

GitHub Actions / uitest-workspaces

Cannot find name 'global'.
})

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

Check failure on line 64 in packages/ui/src/__test__/file-embed-utils.test.ts

View workflow job for this annotation

GitHub Actions / build

Cannot find name 'global'.

Check failure on line 64 in packages/ui/src/__test__/file-embed-utils.test.ts

View workflow job for this annotation

GitHub Actions / uitest-workspaces

Cannot find name 'global'.
ok: true,
blob: async () => body
})

await expect(fetchAsObjectUrl('https://dl.huly.app/blob/ws/file', 'tok')).resolves.toEqual({
url: 'blob:https://huly.app/generated',
owned: true
})
expect(global.fetch).toHaveBeenCalledWith('https://dl.huly.app/blob/ws/file', {

Check failure on line 73 in packages/ui/src/__test__/file-embed-utils.test.ts

View workflow job for this annotation

GitHub Actions / build

Cannot find name 'global'.

Check failure on line 73 in packages/ui/src/__test__/file-embed-utils.test.ts

View workflow job for this annotation

GitHub Actions / uitest-workspaces

Cannot find name 'global'.
headers: { Authorization: 'Bearer tok' },
signal: undefined
})
expect(URL.createObjectURL).toHaveBeenCalledWith(body)
})

it('does not send credentials via the Authorization header when no token is given', async () => {
;(global.fetch as jest.Mock).mockResolvedValue({

Check failure on line 81 in packages/ui/src/__test__/file-embed-utils.test.ts

View workflow job for this annotation

GitHub Actions / build

Cannot find name 'global'.

Check failure on line 81 in packages/ui/src/__test__/file-embed-utils.test.ts

View workflow job for this annotation

GitHub Actions / uitest-workspaces

Cannot find name 'global'.
ok: true,
blob: async () => new Blob(['x'])
})

await fetchAsObjectUrl('/files/ws/file')
expect(global.fetch).toHaveBeenCalledWith('/files/ws/file', {

Check failure on line 87 in packages/ui/src/__test__/file-embed-utils.test.ts

View workflow job for this annotation

GitHub Actions / build

Cannot find name 'global'.

Check failure on line 87 in packages/ui/src/__test__/file-embed-utils.test.ts

View workflow job for this annotation

GitHub Actions / uitest-workspaces

Cannot find name 'global'.
headers: {},
signal: undefined
})
})

it('throws when the file server rejects the request', async () => {
;(global.fetch as jest.Mock).mockResolvedValue({ ok: false, status: 401 })

Check failure on line 94 in packages/ui/src/__test__/file-embed-utils.test.ts

View workflow job for this annotation

GitHub Actions / build

Cannot find name 'global'.

Check failure on line 94 in packages/ui/src/__test__/file-embed-utils.test.ts

View workflow job for this annotation

GitHub Actions / uitest-workspaces

Cannot find name 'global'.
await expect(fetchAsObjectUrl('https://dl.huly.app/blob/ws/file', 'tok')).rejects.toThrow(
'Failed to fetch file: 401'
)
expect(URL.createObjectURL).not.toHaveBeenCalled()
})
})
31 changes: 3 additions & 28 deletions packages/ui/src/components/EmbeddedHTML.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,39 +14,14 @@
-->

<script lang="ts">
import { onDestroy } from 'svelte'
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 token: string | 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} {token} />
57 changes: 56 additions & 1 deletion packages/ui/src/components/EmbeddedPDF.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,63 @@
-->

<script lang="ts">
import { onDestroy } from 'svelte'
import { fetchAsObjectUrl } 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 token: string | 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, token?: string): Promise<void> {
controller?.abort()
controller = new AbortController()
const { signal } = controller

failed = false
revokeOwned()

try {
const result = await fetchAsObjectUrl(src, token, signal)
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, token)

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

// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
$: if (css !== undefined && iframe !== undefined && iframe !== null) {
Expand All @@ -42,7 +93,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
60 changes: 60 additions & 0 deletions packages/ui/src/file-embed-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// 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.
//

/**
* 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 remote file (with optional Bearer token) and return a blob: URL
* suitable for iframe embedding. Local object URLs are returned as-is.
*/
export async function fetchAsObjectUrl (
src: string,
token?: string,
signal?: AbortSignal
): Promise<{ url: string, owned: boolean }> {
if (isLocalObjectUrl(src)) {
return { url: src, owned: false }
}

const response = await fetch(src, {
headers: authorizationHeaders(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 }
}
2 changes: 1 addition & 1 deletion plugins/print-resources/src/components/DOCXViewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@
<Spinner size="medium" />
</div>
{:else}
<EmbeddedHTML {src} {name} {css} />
<EmbeddedHTML {src} {name} {css} {token} />
{/if}
{/if}

Expand Down
7 changes: 5 additions & 2 deletions plugins/view-resources/src/components/viewer/PDFViewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@
-->
<script lang="ts">
import { type Blob, type Ref } from '@hcengineering/core'
import { getFileUrl } from '@hcengineering/presentation'
import { getMetadata } from '@hcengineering/platform'
import presentation, { getFileUrl } from '@hcengineering/presentation'
import { EmbeddedPDF } from '@hcengineering/ui'

export let value: Ref<Blob>
export let name: string
export let fit: boolean = false

const token = getMetadata(presentation.metadata.Token)
</script>

<EmbeddedPDF src={getFileUrl(value, name)} {name} {fit} />
<EmbeddedPDF src={getFileUrl(value, name)} {name} {fit} {token} />
Loading