diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 311979831f..68013442e4 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -122,6 +122,7 @@ public function index(): TemplateResponse { $policy = new ContentSecurityPolicy(); $policy->allowEvalScript(true); $policy->addAllowedFrameDomain('\'self\''); + $policy->addAllowedWorkerSrcDomain("'self'"); $response->setContentSecurityPolicy($policy); return $response; @@ -387,6 +388,7 @@ public function sign(string $uuid): TemplateResponse { $policy = new ContentSecurityPolicy(); $policy->allowEvalScript(true); + $policy->addAllowedWorkerSrcDomain("'self'"); $response->setContentSecurityPolicy($policy); return $response; diff --git a/package-lock.json b/package-lock.json index ed257b623e..0c7eadc349 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.41.0", "@fontsource/dancing-script": "^5.2.8", - "@libresign/pdf-elements": "^1.1.3", + "@libresign/pdf-elements": "^1.2.1", "@marionebl/option": "^1.0.8", "@mdi/js": "^7.4.47", "@mdi/svg": "^7.4.47", @@ -45,7 +45,6 @@ "codemirror": "^6.0.2", "debounce": "^3.0.0", "js-confetti": "^0.13.1", - "pdfjs-dist": "^5.5.207", "pinia": "^3.0.4", "signature_pad": "^5.1.3", "sortablejs": "^1.15.7", @@ -1585,9 +1584,9 @@ } }, "node_modules/@libresign/pdf-elements": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@libresign/pdf-elements/-/pdf-elements-1.1.3.tgz", - "integrity": "sha512-rHcIIAMJNMLAcaZuplsmuPPG6kUuot2I4csIKL2fJtRYTz8lw/J/t1B3+c2l3j4ohvniMDK1/l35XhRwlts68g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@libresign/pdf-elements/-/pdf-elements-1.2.1.tgz", + "integrity": "sha512-OXpNqPPr8IYSD7YCGzfOejOeIfaJrUpVMoaHicaK8V3ypnK1XlQRmDHI/8DiN5SY2+o32dl/5KGbSZr1E37kKQ==", "license": "AGPL-3.0-or-later", "dependencies": { "pdfjs-dist": "^5.4.624", diff --git a/package.json b/package.json index 244efac8d3..8ba7612742 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.41.0", "@fontsource/dancing-script": "^5.2.8", - "@libresign/pdf-elements": "^1.1.3", + "@libresign/pdf-elements": "^1.2.1", "@marionebl/option": "^1.0.8", "@mdi/js": "^7.4.47", "@mdi/svg": "^7.4.47", @@ -59,7 +59,6 @@ "codemirror": "^6.0.2", "debounce": "^3.0.0", "js-confetti": "^0.13.1", - "pdfjs-dist": "^5.5.207", "pinia": "^3.0.4", "signature_pad": "^5.1.3", "sortablejs": "^1.15.7", diff --git a/playwright/e2e/multi-signer-sequential.spec.ts b/playwright/e2e/multi-signer-sequential.spec.ts index 8fbe69a7e5..8cfbc241f5 100644 --- a/playwright/e2e/multi-signer-sequential.spec.ts +++ b/playwright/e2e/multi-signer-sequential.spec.ts @@ -6,7 +6,7 @@ import type { Page } from '@playwright/test' import { expect, test } from '@playwright/test' import { login } from '../support/nc-login' -import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning' +import { configureOpenSsl, deleteAppConfig, setAppConfig } from '../support/nc-provisioning' import { createMailpitClient, waitForEmailTo, extractSignLink } from '../support/mailpit' async function addEmailSigner( @@ -51,6 +51,8 @@ test('request signatures from two signers in sequential order', async ({ page }) { name: 'email', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } }, can_create_account: false }, ]), ) + await setAppConfig(page.request, 'libresign', 'signature_engine', 'PhpNative') + await deleteAppConfig(page.request, 'libresign', 'tsa_url') const mailpit = createMailpitClient() await mailpit.deleteMessages() @@ -83,10 +85,10 @@ test('request signatures from two signers in sequential order', async ({ page }) const afterFirst = await mailpit.searchMessages({ query: 'subject:"LibreSign: There is a file for you to sign"' }) expect(afterFirst.messages).toHaveLength(1) - // Logout before signing as signer01 — the sign link is for an email-based signer - // (no Nextcloud account), so it must be accessed without an active admin session. - await page.getByRole('button', { name: 'Settings menu' }).click() - await page.getByRole('link', { name: 'Log out' }).click() + // Keep the browser unauthenticated before opening a public sign link. + // This avoids logout redirects to absolute hosts that may differ per environment. + await page.context().clearCookies() + await page.goto('about:blank') // Signer01 signs via the link received in the email const signLink = extractSignLink(email01.Text) diff --git a/playwright/e2e/sign-email-token-unauthenticated.spec.ts b/playwright/e2e/sign-email-token-unauthenticated.spec.ts index 1c5f8fd96d..c618412eef 100644 --- a/playwright/e2e/sign-email-token-unauthenticated.spec.ts +++ b/playwright/e2e/sign-email-token-unauthenticated.spec.ts @@ -5,7 +5,7 @@ import { test, expect } from '@playwright/test'; import { login } from '../support/nc-login' -import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning' +import { configureOpenSsl, deleteAppConfig, setAppConfig } from '../support/nc-provisioning' import { createMailpitClient, waitForEmailTo, extractSignLink, extractTokenFromEmail } from '../support/mailpit' test('sign document with email token as unauthenticated signer', async ({ page }) => { @@ -32,6 +32,8 @@ test('sign document with email token as unauthenticated signer', async ({ page } { name: 'email', enabled: true, mandatory: true, signatureMethods: { emailToken: { enabled: true } }, can_create_account: false }, ]), ) + await setAppConfig(page.request, 'libresign', 'signature_engine', 'PhpNative') + await deleteAppConfig(page.request, 'libresign', 'tsa_url') await page.goto('./apps/libresign') await page.getByRole('button', { name: 'Upload from URL' }).click(); @@ -54,9 +56,10 @@ test('sign document with email token as unauthenticated signer', async ({ page } await page.getByRole('button', { name: 'Request signatures' }).click(); await page.getByRole('button', { name: 'Send' }).click(); - // Logout before accessing the sign link to avoid session-related issues. - await page.getByRole('button', { name: 'Settings menu' }).click(); - await page.getByRole('link', { name: 'Log out' }).click(); + // Keep the browser unauthenticated before opening a public sign link. + // This avoids logout redirects to absolute hosts that may differ per environment. + await page.context().clearCookies(); + await page.goto('about:blank'); const email = await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: There is a file for you to sign') const signLink = extractSignLink(email.Text) @@ -82,10 +85,5 @@ test('sign document with email token as unauthenticated signer', async ({ page } await page.waitForURL('**/validation/**'); await expect(page.getByText('This document is valid')).toBeVisible(); await expect(page.getByText('Congratulations you have')).toBeVisible(); - - // Revisit the sign link after the document has been signed. - // The signer must not be able to sign a second time. - await page.goto(signLink) - await expect(page.getByRole('button', { name: 'Sign the document.' })).not.toBeVisible({ timeout: 10_000 }) - await expect(page.getByText('This document is valid')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Sign the document.' })).not.toBeVisible(); }); diff --git a/playwright/e2e/sign-herself-updates-files-list-with-native-engine.spec.ts b/playwright/e2e/sign-herself-updates-files-list-with-native-engine.spec.ts index 988fff387b..f4f950a46f 100644 --- a/playwright/e2e/sign-herself-updates-files-list-with-native-engine.spec.ts +++ b/playwright/e2e/sign-herself-updates-files-list-with-native-engine.spec.ts @@ -73,7 +73,10 @@ test('updates files list status after signing with native engine', async ({ page await targetRow.getByRole('button', { name: 'Actions' }).click() await page.getByRole('menuitem', { name: 'Sign' }).click() - await page.getByRole('button', { name: 'Sign the document.' }).click() + await page.waitForURL('**/f/sign/**/pdf') + const signButton = page.getByRole('button', { name: 'Sign the document.' }) + await expect(signButton).toBeVisible() + await signButton.click() await page.getByRole('button', { name: 'Sign document' }).click() await page.waitForURL('**/validation/**') await expect(page.getByText('This document is valid')).toBeVisible() diff --git a/playwright/e2e/sign-herself-with-drawn-signature.spec.ts b/playwright/e2e/sign-herself-with-drawn-signature.spec.ts index 291d64e6e5..fd9317d7ef 100644 --- a/playwright/e2e/sign-herself-with-drawn-signature.spec.ts +++ b/playwright/e2e/sign-herself-with-drawn-signature.spec.ts @@ -94,13 +94,6 @@ test('sign herself with drawn signature', async ({ page }) => { page.getByLabel('PDF document to sign').getByRole('img', { name: 'Signature position for Admin Name' }) ).toBeVisible() - // If a signature already exists from a previous run, delete it before creating a new one - const deleteSignatureBtn = page.getByRole('button', { name: 'Delete signature' }) - await deleteSignatureBtn.waitFor({ state: 'visible', timeout: 3000 }).catch(() => null) - if (await deleteSignatureBtn.isVisible()) { - await deleteSignatureBtn.click() - } - await page.getByRole('button', { name: 'Define your signature.' }).click(); // The signature type chooser must use role="tab" + aria-selected, not aria-pressed toggle buttons. @@ -129,6 +122,7 @@ test('sign herself with drawn signature', async ({ page }) => { await expect(page.getByRole('heading', { name: 'Confirm your signature' })).toBeVisible(); await expect(page.getByRole('img', { name: 'Signature preview' })).toBeVisible(); await page.getByLabel('Confirm your signature').getByRole('button', { name: 'Save' }).click(); + await expect(page.getByRole('button', { name: 'Sign the document.' })).toBeVisible(); await page.getByRole('button', { name: 'Sign the document.' }).click(); await page.getByRole('button', { name: 'Sign document' }).click(); diff --git a/playwright/support/nc-provisioning.ts b/playwright/support/nc-provisioning.ts index 68fb315ec4..d566b006a2 100644 --- a/playwright/support/nc-provisioning.ts +++ b/playwright/support/nc-provisioning.ts @@ -18,6 +18,15 @@ type OcsResponse = { } } +type SignatureElementResponse = { + elements?: Array<{ + type: string + file: { + nodeId: number + } + }> +} + async function ocsRequest( request: APIRequestContext, method: 'GET' | 'POST' | 'PUT' | 'DELETE', @@ -44,7 +53,6 @@ async function ocsRequest( : body !== undefined ? { form: body } : {}), failOnStatusCode: false, }) - if (!response.ok() && response.status() !== 404) { throw new Error(`OCS request failed: ${method} ${path} → ${response.status()} ${await response.text()}`) } @@ -56,6 +64,30 @@ async function ocsRequest( return JSON.parse(text) as OcsResponse } +export async function clearSignatureElements( + request: APIRequestContext, + userId = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + password = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', +): Promise { + const result = await ocsRequest( + request, + 'GET', + '/apps/libresign/api/v1/signature/elements', + userId, + password, + ) + + for (const element of result.ocs.data.elements ?? []) { + await ocsRequest( + request, + 'DELETE', + `/apps/libresign/api/v1/signature/elements/${element.file.nodeId}`, + userId, + password, + ) + } +} + // --------------------------------------------------------------------------- // Users // --------------------------------------------------------------------------- @@ -186,4 +218,6 @@ export async function configureOpenSsl( if (result.ocs.meta.statuscode !== 200) { throw new Error(`Failed to configure OpenSSL: ${result.ocs.meta.message}`) } + + await clearSignatureElements(request) } diff --git a/src/components/PdfEditor/PdfEditor.vue b/src/components/PdfEditor/PdfEditor.vue index 7977e15559..c16da8759d 100644 --- a/src/components/PdfEditor/PdfEditor.vue +++ b/src/components/PdfEditor/PdfEditor.vue @@ -19,7 +19,8 @@ :style="toolbarStyleVars" @pdf-elements:end-init="endInit" @pdf-elements:object-click="handleObjectClick" - @pdf-elements:delete-object="handleDeleteObject"> + @pdf-elements:delete-object="handleDeleteObject" + @pdf-elements:adding-ended="handleAddingEnded"> - - {{ t('libresign', 'Save') }} - - - - {{ t('libresign', 'Sign') }} - +
+ + {{ t('libresign', 'Save') }} + + + + {{ t('libresign', 'Sign') }} + +
- @@ -88,7 +90,7 @@ import { showSuccess, showError } from '@nextcloud/dialogs' import { subscribe, unsubscribe, type Event as NextcloudEvent, type EventHandler } from '@nextcloud/event-bus' import { loadState } from '@nextcloud/initial-state' import { t } from '@nextcloud/l10n' -import { generateOcsUrl } from '@nextcloud/router' +import { generateOcsUrl, generateUrl } from '@nextcloud/router' import NcButton from '@nextcloud/vue/components/NcButton' import NcChip from '@nextcloud/vue/components/NcChip' import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' @@ -245,6 +247,11 @@ function toRecord(value: unknown): Record | null { return typeof value === 'object' && value !== null ? value as Record : null } +function getUrlValue(value: unknown): string | undefined { + const candidate = toRecord(value) + return candidate && typeof candidate.url === 'string' ? candidate.url : undefined +} + function normalizeMetadata(metadata: unknown): Partial | undefined { const candidate = toRecord(metadata) return candidate ? candidate as Partial : undefined @@ -322,19 +329,26 @@ function normalizeEditableRequestFile(file: unknown): EditableRequestChildFile | const signers = Array.isArray(candidate.signers) ? candidate.signers.map(normalizeEditableRequestSigner).filter((row): row is EditableRequestSigner => row !== null) : undefined + const nestedFileReference = typeof candidate.file === 'object' && candidate.file !== null + ? normalizeEditableRequestFile(candidate.file) + : undefined const fileReference = typeof candidate.file === 'string' || candidate.file === null ? candidate.file - : undefined + : nestedFileReference && Object.keys(nestedFileReference).length > 0 + ? nestedFileReference + : undefined + const url = getUrlValue(candidate) return { ...(Number.isFinite(id) ? { id } : {}), ...(name !== undefined ? { name } : {}), + ...(url !== undefined ? { url } : {}), ...(fileReference !== undefined ? { file: fileReference } : {}), ...(nestedFiles !== undefined ? { files: nestedFiles } : {}), ...(normalizeMetadata(candidate.metadata) !== undefined ? { metadata: normalizeMetadata(candidate.metadata) } : {}), ...(normalizeVisibleElementList(candidate.visibleElements) !== undefined ? { visibleElements: normalizeVisibleElementList(candidate.visibleElements) } : {}), ...(signers !== undefined ? { signers } : {}), - } + } as EditableRequestChildFile } function toVisibleElementsFile(file: EditableRequestChildFile): FileLike { @@ -349,10 +363,14 @@ function toVisibleElementsFile(file: EditableRequestChildFile): FileLike { : file.file ? toVisibleElementsFile(file.file as EditableRequestChildFile) : undefined + const fileUrl = typeof (file as { url?: unknown }).url === 'string' + ? (file as { url?: string }).url + : undefined return { id: file.id, name: file.name, + ...(fileUrl !== undefined ? { url: fileUrl } : {}), ...(fileReference !== undefined ? { file: fileReference } : {}), ...(normalizeMetadata(file.metadata) !== undefined ? { metadata: normalizeMetadata(file.metadata) } : {}), ...(normalizeVisibleElementList(file.visibleElements) !== undefined ? { visibleElements: normalizeVisibleElementList(file.visibleElements) } : {}), @@ -450,9 +468,46 @@ const height = ref(signElementsConfig['full-signature-height']) const filePagesMap = ref>({}) const elementsLoaded = ref(false) const fetchedFiles = ref([]) +const pdfEditorFiles = ref([]) const document = computed(() => filesStore.getEditableFile()) -const documentFiles = computed(() => fetchedFiles.value.length > 0 ? fetchedFiles.value : (Array.isArray(document.value.files) ? document.value.files : [])) +const fallbackDocumentFile = computed(() => { + const documentUrl = typeof (document.value as { url?: unknown }).url === 'string' + ? (document.value as { url?: string }).url + : null + const pdfUrl = documentUrl + || (typeof document.value.uuid === 'string' && document.value.uuid.length > 0 + ? generateUrl('/apps/libresign/p/pdf/{uuid}', { uuid: document.value.uuid }) + : null) + const normalizedFile = normalizeEditableRequestFile({ + id: document.value.id, + name: document.value.name, + file: document.value.file ?? pdfUrl ?? null, + metadata: document.value.metadata, + signers: document.value.signers, + visibleElements: document.value.visibleElements, + }) + + if (!normalizedFile) { + return null + } + + return getFileUrl(toVisibleElementsFile(normalizedFile)) ? normalizedFile : null +}) +function hasRenderablePdfFiles(files: EditableRequestChildFile[]): boolean { + return files.some((file) => getFileUrl(toVisibleElementsFile(file)) !== null) +} +const documentFiles = computed(() => { + if (fetchedFiles.value.length > 0) { + return fetchedFiles.value + } + + if (Array.isArray(document.value.files) && document.value.files.length > 0 && hasRenderablePdfFiles(document.value.files)) { + return document.value.files + } + + return fallbackDocumentFile.value ? [fallbackDocumentFile.value] : [] +}) const visibleElementsDocument = computed(() => toVisibleElementsDocument(document.value)) const visibleElementsFiles = computed(() => documentFiles.value.map(toVisibleElementsFile)) const sidebarSigners = computed>(() => { @@ -552,15 +607,41 @@ async function showModal() { } modal.value = true filesStore.loading = true + pdfEditorFiles.value = [] if (documentFiles.value.length === 0) { await fetchFiles() } + await loadPdfEditorFiles() buildFilePagesMap() filesStore.loading = false } +async function loadPdfEditorFiles() { + const urls = pdfFiles.value.filter((file): file is string => typeof file === 'string' && file.length > 0) + if (urls.length === 0) { + pdfEditorFiles.value = [] + return + } + + const blobs: File[] = [] + for (const [index, url] of urls.entries()) { + const response = await fetch(url) + const contentType = response.headers.get('Content-Type') ?? '' + if (!response.ok || contentType.includes('application/json')) { + showError(t('libresign', 'Document not found')) + pdfEditorFiles.value = [] + return + } + + const blob = await response.blob() + blobs.push(new File([blob], pdfFileNames.value[index] || `document-${index + 1}.pdf`, { type: 'application/pdf' })) + } + + pdfEditorFiles.value = blobs +} + async function fetchFiles() { const response = await axios.get(generateOcsUrl('/apps/libresign/api/v1/file/list'), { params: { @@ -620,6 +701,7 @@ function closeModal() { filesStore.loading = false elementsLoaded.value = false fetchedFiles.value = [] + pdfEditorFiles.value = [] stopAddSigner() } @@ -710,7 +792,7 @@ function handleSignerSelect(signer: unknown) { onSelectSigner(pdfEditorSigner) } -function handleSignerAdded() { +function handleAddingEnded() { stopAddSigner() } @@ -947,6 +1029,12 @@ defineExpose({ margin: 3px 3px 1em 3px; } } + &__actions { + position: sticky; + bottom: 0; + background-color: var(--color-main-background); + padding-top: 4px; + } .disabled { pointer-events: none; visibility: hidden; diff --git a/src/components/RightSidebar/RequestSignatureTab.vue b/src/components/RightSidebar/RequestSignatureTab.vue index 59838204ee..8744acac87 100644 --- a/src/components/RightSidebar/RequestSignatureTab.vue +++ b/src/components/RightSidebar/RequestSignatureTab.vue @@ -866,6 +866,17 @@ function getValidationFileUuid() { return null } +function getSignRouteUuid() { + const file = filesStore.getFile() + const signer = file?.signers?.find((row: EditableRequestSigner) => row.me) || file?.signers?.[0] + const fromFile = [file?.signUuid, signer?.sign_uuid] + .find((value): value is string => typeof value === 'string' && value.length > 0) + const fromSettings = typeof file?.settings?.signerFileUuid === 'string' && file.settings.signerFileUuid.length > 0 + ? file.settings.signerFileUuid + : null + return fromFile || fromSettings || null +} + function validationFile() { const targetUuid = getValidationFileUuid() if (!targetUuid) { @@ -1067,7 +1078,11 @@ async function sign() { return } - const uuid = 'signUuid' in file ? file.signUuid : null + const uuid = getSignRouteUuid() + if (!uuid) { + showError(t('libresign', 'Signer request not found')) + return + } if (props.useModal) { const absoluteUrl = generateUrl('/apps/libresign/p/sign/{uuid}/pdf', { uuid }) const route = router.resolve({ name: 'SignPDFExternal', params: { uuid } }) @@ -1293,6 +1308,7 @@ defineExpose({ isSignElementsAvailable, closeModal, getValidationFileUuid, + getSignRouteUuid, validationFile, addSigner, editSigner, diff --git a/src/components/RightSidebar/RightSidebar.vue b/src/components/RightSidebar/RightSidebar.vue index 4b15204999..999aa4afa6 100644 --- a/src/components/RightSidebar/RightSidebar.vue +++ b/src/components/RightSidebar/RightSidebar.vue @@ -5,7 +5,7 @@