diff --git a/playwright/e2e/sign-envelope-unauthenticated-visible-signature.spec.ts b/playwright/e2e/sign-envelope-unauthenticated-visible-signature.spec.ts
index d2cee78a4b..3e93e95a17 100644
--- a/playwright/e2e/sign-envelope-unauthenticated-visible-signature.spec.ts
+++ b/playwright/e2e/sign-envelope-unauthenticated-visible-signature.spec.ts
@@ -87,6 +87,9 @@ async function enableEnvelopeScenario(request: APIRequestContext) {
await setCertificateEngine(request, 'openssl')
+ // This flow opens signed PDFs as an anonymous signer; do not inherit a
+ // private validation policy left by another Playwright scenario.
+ await setSystemPolicy(request, 'make_validation_url_private', '0')
await setSystemPolicy(request, 'envelope_enabled', '1')
await setSystemPolicy(request, 'identification_documents', JSON.stringify({ enabled: false, approvers: ['admin'] }))
await setSystemPolicy(
@@ -160,7 +163,7 @@ async function waitForSignerInvitationLink(signerEmail: string) {
const email = await waitForEmailTo(
createMailpitClient(),
signerEmail,
- 'LibreSign: A document is ready for your signature',
+ undefined, // Notification language can differ from the test browser locale.
{ interval: 250 },
)
const signLink = extractSignLink(email.Text)
@@ -285,4 +288,36 @@ test('unauthenticated signer can define a visible signature for an envelope with
await test.step('Then the success confirmation screen is shown with the envelope name', async () => {
await expectEnvelopeSigned(page, scenario.envelopeName)
})
+
+ await test.step('The signed PDF opens by UUID without using an Office handler', async () => {
+ const context = page.context()
+ const officeRequests: string[] = []
+ context.on('request', request => {
+ if (request.url().includes('/apps/eurooffice/')) {
+ officeRequests.push(request.url())
+ }
+ })
+
+ // The legacy Viewer cannot load anonymous PDFs through authenticated
+ // Files APIs. Check the direct URL fallback in this public signing flow.
+ const popupPromise = page.waitForEvent('popup')
+ const pdfResponse = context.waitForEvent('response', response =>
+ response.request().method() === 'GET'
+ && response.url().includes('/apps/libresign/p/pdf/'),
+ )
+ await page.getByRole('button', { name: 'Show details' }).first().click()
+ await page.getByRole('button', { name: 'View document' }).first().click()
+
+ const popup = await popupPromise
+ expect(await popup.opener()).toBe(page)
+ const response = await pdfResponse
+ expect(response.status()).toBe(200)
+ expect(response.headers()['content-type']).toContain('application/pdf')
+ expect(new URL(response.url()).pathname).toMatch(/\/apps\/libresign\/p\/pdf\/[^/]+$/)
+
+ const signedPdf = await page.request.get(response.url())
+ expect(signedPdf.status()).toBe(200)
+ expect((await signedPdf.body()).toString('latin1')).toContain('/ByteRange')
+ expect(officeRequests).toHaveLength(0)
+ })
})
diff --git a/src/components/validation/DocumentValidationDetails.vue b/src/components/validation/DocumentValidationDetails.vue
index ed337688b0..a92bfb08df 100644
--- a/src/components/validation/DocumentValidationDetails.vue
+++ b/src/components/validation/DocumentValidationDetails.vue
@@ -99,14 +99,13 @@ const size = computed(() => {
const documentStatus = computed(() => getStatusLabel(document.value.status))
async function viewDocument() {
- if (!document.value.uuid || !document.value.name || typeof document.value.nodeId !== 'number') {
+ if (!document.value.uuid || !document.value.name) {
return
}
const fileUrl = generateUrl('/apps/libresign/p/pdf/{uuid}', { uuid: document.value.uuid })
await openDocument({
fileUrl,
filename: document.value.name,
- nodeId: document.value.nodeId,
})
}
diff --git a/src/components/validation/EnvelopeValidation.vue b/src/components/validation/EnvelopeValidation.vue
index e530f1f9cf..b0cc883e85 100644
--- a/src/components/validation/EnvelopeValidation.vue
+++ b/src/components/validation/EnvelopeValidation.vue
@@ -65,7 +65,7 @@
{{ t('libresign', 'Status:') }} {{ getFileStatusText(file) }}
-
+
@@ -74,7 +74,7 @@
-
+
@@ -278,14 +278,13 @@ function getSignerProgressText(signer: EnvelopeSigner) {
}
function viewFile(file: EnvelopeFile) {
- if (!file.uuid || !file.name || typeof file.nodeId !== 'number') {
+ if (!file.uuid || !file.name) {
return
}
const fileUrl = generateUrl('/apps/libresign/p/pdf/{uuid}', { uuid: file.uuid })
- openDocument({
+ return openDocument({
fileUrl,
filename: file.name,
- nodeId: file.nodeId,
})
}
diff --git a/src/tests/components/validation/DocumentValidationDetails.spec.ts b/src/tests/components/validation/DocumentValidationDetails.spec.ts
index fc291a1b32..26cb8d92cd 100644
--- a/src/tests/components/validation/DocumentValidationDetails.spec.ts
+++ b/src/tests/components/validation/DocumentValidationDetails.spec.ts
@@ -398,7 +398,7 @@ describe('DocumentValidationDetails', () => {
expect(wrapper.vm.document.uuid).toBeUndefined()
})
- it('calls openDocument with correct parameters', async () => {
+ it('opens the signed PDF by UUID without passing the owner nodeId', async () => {
wrapper = createWrapper({
document: {
uuid: 'doc-uuid-123',
@@ -410,9 +410,24 @@ describe('DocumentValidationDetails', () => {
await wrapper.vm.viewDocument()
expect(viewer.openDocument).toHaveBeenCalledWith({
- fileUrl: expect.stringContaining('doc-uuid-123'),
+ fileUrl: '/apps/libresign/p/pdf/doc-uuid-123',
filename: 'Test.pdf',
- nodeId: 456,
+ })
+ })
+
+ it('opens the document when the response has no nodeId', async () => {
+ wrapper = createWrapper({
+ document: {
+ uuid: 'no-node-id',
+ name: 'No nodeId.pdf',
+ },
+ })
+
+ await wrapper.vm.viewDocument()
+
+ expect(viewer.openDocument).toHaveBeenCalledWith({
+ fileUrl: '/apps/libresign/p/pdf/no-node-id',
+ filename: 'No nodeId.pdf',
})
})
@@ -451,8 +466,8 @@ describe('DocumentValidationDetails', () => {
it('does not open viewer without required viewer data', async () => {
wrapper = createWrapper({
document: {
- uuid: 'missing-node-id',
- name: 'Missing nodeId.pdf',
+ name: 'Missing uuid.pdf',
+ nodeId: 456,
},
})
diff --git a/src/tests/components/validation/EnvelopeValidation.spec.ts b/src/tests/components/validation/EnvelopeValidation.spec.ts
index d3f9414957..b0bf9df234 100644
--- a/src/tests/components/validation/EnvelopeValidation.spec.ts
+++ b/src/tests/components/validation/EnvelopeValidation.spec.ts
@@ -361,13 +361,12 @@ describe('EnvelopeValidation', () => {
})
expect(viewer.openDocument).toHaveBeenCalledWith({
- fileUrl: expect.stringContaining('file-uuid-123'),
+ fileUrl: '/apps/libresign/p/pdf/file-uuid-123',
filename: 'Document.pdf',
- nodeId: 456,
})
})
- it('does not open viewer without nodeId', () => {
+ it('opens the file by UUID when it has no nodeId', () => {
wrapper = createWrapper()
wrapper.vm.viewFile({
@@ -375,6 +374,20 @@ describe('EnvelopeValidation', () => {
name: 'Document.pdf',
})
+ expect(viewer.openDocument).toHaveBeenCalledWith({
+ fileUrl: '/apps/libresign/p/pdf/file-uuid-123',
+ filename: 'Document.pdf',
+ })
+ })
+
+ it('does not open viewer without uuid', () => {
+ wrapper = createWrapper()
+
+ wrapper.vm.viewFile({
+ name: 'Document.pdf',
+ nodeId: 456,
+ })
+
expect(viewer.openDocument).not.toHaveBeenCalled()
})
})
@@ -512,8 +525,8 @@ describe('EnvelopeValidation', () => {
expect(typeof wrapper.vm.isTouchDevice).toBe('boolean')
})
- it('renders actions slot when not touch device and file has nodeId', async () => {
- const file: EnvelopeFile = { id: 1, nodeId: 123, status: '3', name: 'test.pdf' }
+ it('renders actions slot when not touch device and file has uuid', async () => {
+ const file: EnvelopeFile = { id: 1, uuid: 'test-uuid', nodeId: 123, status: '3', name: 'test.pdf' }
wrapper = createWrapper({
document: { files: [file] },
})
@@ -536,7 +549,6 @@ describe('EnvelopeValidation', () => {
expect(viewer.openDocument).toHaveBeenCalledWith({
fileUrl: '/apps/libresign/p/pdf/test-uuid',
filename: 'test.pdf',
- nodeId: 123,
})
})
@@ -551,7 +563,6 @@ describe('EnvelopeValidation', () => {
expect(viewer.openDocument).toHaveBeenCalledWith({
fileUrl: '/apps/libresign/p/pdf/test-uuid',
filename: 'test.pdf',
- nodeId: 123,
})
})
})
diff --git a/src/tests/utils/viewer.spec.ts b/src/tests/utils/viewer.spec.ts
index 420fc35fd3..b0809ad2f6 100644
--- a/src/tests/utils/viewer.spec.ts
+++ b/src/tests/utils/viewer.spec.ts
@@ -3,82 +3,108 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({ getCurrentUser: vi.fn() }))
+vi.mock('@nextcloud/auth', () => ({ getCurrentUser: mocks.getCurrentUser }))
+
import { openDocument } from '../../utils/viewer.js'
-type GlobalWithOCA = typeof globalThis & {
- OCA?: {
- Viewer?: {
- open?: (params: unknown) => void
- }
- [key: string]: unknown
- }
+type LegacyViewer = {
+ availableHandlers?: Array<{ id: string; mimes?: string[] }>
+ open?: ReturnType
+ openWith?: ReturnType
}
+type GlobalWithOCA = typeof globalThis & { OCA?: { Viewer?: LegacyViewer } }
-describe('openDocument', () => {
+describe('openDocument on Nextcloud 33-35', () => {
let originalOCA: GlobalWithOCA['OCA']
let openSpy: ReturnType
+ let viewer: Required
beforeEach(() => {
- originalOCA = (global as GlobalWithOCA).OCA
+ originalOCA = (globalThis as GlobalWithOCA).OCA
openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
+ mocks.getCurrentUser.mockReturnValue({ uid: 'signer' })
+ viewer = {
+ availableHandlers: [
+ { id: 'eurooffice', mimes: ['application/pdf'] },
+ { id: 'pdf', mimes: ['application/pdf'] },
+ ],
+ open: vi.fn(),
+ openWith: vi.fn(),
+ }
+ ;(globalThis as GlobalWithOCA).OCA = { Viewer: viewer }
})
afterEach(() => {
openSpy.mockRestore()
- ;(global as GlobalWithOCA).OCA = originalOCA
+ ;(globalThis as GlobalWithOCA).OCA = originalOCA
+ vi.clearAllMocks()
})
- it('uses Nextcloud Viewer when available', () => {
- const viewerOpen = vi.fn()
- ;(global as GlobalWithOCA).OCA = {
- Viewer: {
- open: viewerOpen,
- },
- }
-
- const file = {
- filename: 'doc.pdf',
- nodeId: 123,
- url: 'https://example.test/index.php/apps/files/?file=/doc.pdf',
- }
-
+ it.each(['owner', 'signer without access to the original'])('uses native PDF handler without original fileid for %s', () => {
openDocument({
- fileUrl: file.url,
- filename: file.filename,
- nodeId: file.nodeId,
+ fileUrl: '/index.php/apps/libresign/p/pdf/signed-uuid',
+ filename: 'signed.pdf',
+ nodeId: 123,
+ })
+ expect(viewer.openWith).toHaveBeenCalledOnce()
+ const [id, options] = viewer.openWith.mock.calls[0]
+ expect(id).toBe('pdf')
+ expect(options.fileInfo).toEqual({
+ source: '/index.php/apps/libresign/p/pdf/signed-uuid',
+ basename: 'signed.pdf',
+ mime: 'application/pdf',
})
+ expect(options.list).toEqual([options.fileInfo])
+ expect(viewer.open).not.toHaveBeenCalled()
+ expect(openSpy).not.toHaveBeenCalled()
+ })
- expect(viewerOpen).toHaveBeenCalledTimes(1)
- const payload = viewerOpen.mock.calls[0]?.[0]
- if (payload) {
- expect(payload.fileInfo.basename).toBe('doc.pdf')
- expect(payload.fileInfo.fileid).toBe(123)
- expect(payload.fileInfo.mime).toBe('application/pdf')
- expect(payload.fileInfo.source).toContain('/index.php/apps/files/')
- }
+ it('falls back on public pages even if the PDF handler is registered', () => {
+ mocks.getCurrentUser.mockReturnValue(null)
+ openDocument({ fileUrl: '/apps/libresign/p/pdf/signed-uuid', filename: 'signed.pdf', nodeId: 123 })
+ expect(viewer.openWith).not.toHaveBeenCalled()
+ expect(openSpy).toHaveBeenCalledOnce()
+ expect(new URL(openSpy.mock.calls[0][0] as string).pathname).toBe('/apps/libresign/p/pdf/signed-uuid')
})
- it('opens new window when Viewer is not available', () => {
- ;(global as GlobalWithOCA).OCA = undefined
+ it('does not let Office handlers handle signed UUID PDFs', () => {
+ viewer.availableHandlers = [{ id: 'eurooffice', mimes: ['application/pdf'] }]
+ openDocument({ fileUrl: '/apps/libresign/p/pdf/signed-uuid', filename: 'signed.pdf', nodeId: 456 })
+ expect(viewer.openWith).not.toHaveBeenCalled()
+ expect(viewer.open).not.toHaveBeenCalled()
+ expect(openSpy).toHaveBeenCalledOnce()
+ })
- const file = {
- filename: 'doc.pdf',
- nodeId: 456,
- url: '/apps/files/?file=/doc.pdf',
- }
+ it('falls back when the Viewer is not installed', () => {
+ ;(globalThis as GlobalWithOCA).OCA = undefined
+ openDocument({ fileUrl: '/apps/libresign/p/pdf/signed-uuid?download=1', filename: 'signed.pdf' })
+ const url = new URL(openSpy.mock.calls[0][0] as string)
+ expect(url.searchParams.get('download')).toBe('1')
+ expect(url.searchParams.get('_t')).toMatch(/^\d+$/)
+ })
- openDocument({
- fileUrl: file.url,
- filename: file.filename,
- nodeId: file.nodeId,
+ it('keeps the original file ID for ordinary Nextcloud documents', () => {
+ openDocument({ fileUrl: '/apps/files/?file=/contract.pdf', filename: 'contract.pdf', nodeId: 123 })
+ expect(viewer.open).toHaveBeenCalledWith({
+ fileInfo: { source: '/apps/files/?file=/contract.pdf', basename: 'contract.pdf', mime: 'application/pdf', fileid: 123 },
+ list: [{ source: '/apps/files/?file=/contract.pdf', basename: 'contract.pdf', mime: 'application/pdf', fileid: 123 }],
})
+ expect(viewer.openWith).not.toHaveBeenCalled()
+ })
- expect(openSpy).toHaveBeenCalledTimes(1)
- const openedUrl = openSpy.mock.calls[0]?.[0]
- if (openedUrl) {
- expect(openedUrl).toContain('/apps/files/?file=/doc.pdf')
- expect(openedUrl).toMatch(/_t=\d+$/)
- }
+ it('opens external URLs directly instead of giving them to local handlers', () => {
+ openDocument({ fileUrl: 'https://example.test/file.pdf?lang=pt', filename: 'external.pdf', nodeId: 123 })
+ expect(viewer.open).not.toHaveBeenCalled()
+ expect(viewer.openWith).not.toHaveBeenCalled()
+ expect(new URL(openSpy.mock.calls[0][0] as string).hostname).toBe('example.test')
+ })
+
+ it('propagates Viewer errors rather than silently opening another window', () => {
+ viewer.openWith.mockImplementation(() => { throw new Error('Viewer failed') })
+ expect(() => openDocument({ fileUrl: '/apps/libresign/p/pdf/signed-uuid', filename: 'signed.pdf' })).toThrow('Viewer failed')
+ expect(openSpy).not.toHaveBeenCalled()
})
})
diff --git a/src/utils/viewer.js b/src/utils/viewer.js
index c631ad12d3..3ecfdad1c8 100644
--- a/src/utils/viewer.js
+++ b/src/utils/viewer.js
@@ -3,48 +3,47 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-/**
- * Extracts the relative path from a URL.
- * Handles absolute URLs (e.g., from localhost in dev/proxy environments)
- * and returns only the pathname + search + hash.
- *
- * @param {string} url The URL to extract the path from
- * @return {string} The relative path
- */
-const extractRelativePath = (url) => {
- try {
- const urlObj = new URL(url, window.location.origin)
- return urlObj.pathname + urlObj.search + urlObj.hash
- } catch (e) {
- // If url is already a relative path, return as-is
- return url
- }
-}
+import { getCurrentUser } from '@nextcloud/auth'
/**
- * Opens a document using Nextcloud Viewer or in a new window
+ * Open a document through the Nextcloud 33-35 Viewer where possible.
+ *
+ * Signed PDFs are resolved by LibreSign's UUID endpoint, not by the original
+ * owner's file ID. Selecting the native PDF handler avoids Office handlers
+ * trying to look up that ID in the signer's filesystem.
*
- * @param {object} options - Options for opening the document
- * @param {string} options.fileUrl - The URL of the file to open
- * @param {string} options.filename - The name of the file
- * @param {number} options.nodeId - The Nextcloud node ID
- * @param {string} [options.mime] - The MIME type (defaults to 'application/pdf')
+ * @param {object} options Document options
+ * @param {string} options.fileUrl File URL
+ * @param {string} options.filename Display name
+ * @param {number} [options.nodeId] Nextcloud file ID for ordinary documents
+ * @param {string} [options.mime] MIME type
*/
export const openDocument = ({ fileUrl, filename, nodeId, mime = 'application/pdf' }) => {
- const source = extractRelativePath(fileUrl)
+ const url = new URL(fileUrl, window.location.origin)
+ const isSameOrigin = url.origin === window.location.origin
+ const isSignedPdf = isSameOrigin && mime === 'application/pdf'
+ && /\/apps\/libresign\/p\/pdf\/[^/]+$/.test(url.pathname)
+ const viewer = globalThis.OCA?.Viewer
+ const source = isSameOrigin ? url.pathname + url.search + url.hash : url.href
- if (OCA?.Viewer !== undefined) {
- const fileInfo = {
- source,
- basename: filename,
- mime,
- fileid: nodeId,
+ if (isSignedPdf) {
+ // The legacy Viewer probes authenticated Files APIs on public pages.
+ // Anonymous visitors must use the direct PDF URL instead.
+ const pdfHandler = viewer?.availableHandlers?.some(handler =>
+ handler.id === 'pdf' && handler.mimes?.includes(mime))
+ if (getCurrentUser() !== null && pdfHandler) {
+ const fileInfo = { source, basename: filename, mime }
+ viewer.openWith('pdf', { fileInfo, list: [fileInfo], enableSidebar: false })
+ return
}
- OCA.Viewer.open({
- fileInfo,
- list: [fileInfo],
- })
- } else {
- window.open(`${source}?_t=${Date.now()}`)
+ } else if (viewer && isSameOrigin) {
+ // Keep the existing Viewer behavior for ordinary documents and their IDs.
+ const fileInfo = { source, basename: filename, mime, fileid: nodeId }
+ viewer.open({ fileInfo, list: [fileInfo] })
+ return
}
+
+ // Use direct opening only without a suitable Viewer. Do not swallow errors.
+ url.searchParams.set('_t', String(Date.now()))
+ window.open(url.href)
}
diff --git a/src/views/Validation.vue b/src/views/Validation.vue
index f23e813690..fc86a547e9 100644
--- a/src/views/Validation.vue
+++ b/src/views/Validation.vue
@@ -583,14 +583,13 @@ function getIconValidityPath(signer: ValidationDisplaySigner) {
}
async function viewDocument() {
- if (!document.value?.uuid || !document.value?.name || typeof document.value.nodeId !== 'number') {
+ if (!document.value?.uuid || !document.value?.name) {
return
}
const fileUrl = generateUrl('/apps/libresign/p/pdf/{uuid}', { uuid: document.value.uuid })
await openDocument({
fileUrl,
filename: document.value.name,
- nodeId: document.value.nodeId,
})
}