diff --git a/apps/remix/app/components/general/document/document-certificate-qr-view.tsx b/apps/remix/app/components/general/document/document-certificate-qr-view.tsx index a01e315cf..4f619cce7 100644 --- a/apps/remix/app/components/general/document/document-certificate-qr-view.tsx +++ b/apps/remix/app/components/general/document/document-certificate-qr-view.tsx @@ -9,6 +9,7 @@ import { EnvelopeRenderProvider, useCurrentEnvelopeRender, } from '@documenso/lib/client-only/providers/envelope-render-provider'; +import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; @@ -52,9 +53,12 @@ export const DocumentCertificateQRView = ({ completedDate, token, }: DocumentCertificateQRViewProps) => { - const { data: documentViaUser } = trpc.document.get.useQuery({ - documentId, - }); + const { sessionData } = useOptionalSession(); + + const { data: documentViaUser } = trpc.document.get.useQuery( + { documentId }, + { enabled: !!sessionData?.user }, + ); const [isDialogOpen, setIsDialogOpen] = useState(() => !!documentViaUser); diff --git a/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx b/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx index b51dc5906..f02392fe0 100644 --- a/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx +++ b/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; -import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client'; import { CheckCircle2, Clock8, DownloadIcon, EyeIcon, Loader2 } from 'lucide-react'; @@ -106,7 +105,6 @@ export async function loader({ params, request }: Route.LoaderArgs) { } export default function CompletedSigningPage({ loaderData }: Route.ComponentProps) { - const { _ } = useLingui(); const revalidator = useRevalidator(); const { sessionData } = useOptionalSession(); @@ -159,8 +157,12 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp }); setQrRetryCount(nextRetryCount); - await revalidator.revalidate(); - setIsRetryingQrLink(false); + + try { + await revalidator.revalidate(); + } finally { + setIsRetryingQrLink(false); + } }, [isDocumentAccessValid, qrRetryCount, revalidator]); const isFullyCompleted = isDocumentAccessValid && signingStatus === 'COMPLETED'; @@ -296,7 +298,7 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
- {payload?.message ?? ( - Something went wrong while opening this shared view. - )} -
{qrShareErrorMessage(payload?.code)}
diff --git a/apps/remix/server/api/files/files.ts b/apps/remix/server/api/files/files.ts index b11d1d0a1..62678fd8c 100644 --- a/apps/remix/server/api/files/files.ts +++ b/apps/remix/server/api/files/files.ts @@ -1,8 +1,6 @@ import { sValidator } from '@hono/standard-validator'; -import { DocumentStatus } from '@prisma/client'; -import type { Prisma } from '@prisma/client'; -import { Hono } from 'hono'; -import type { Context } from 'hono'; +import { DocumentStatus, type Prisma } from '@prisma/client'; +import { type Context, Hono } from 'hono'; import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; @@ -58,7 +56,7 @@ const envelopeItemTokenInclude = { } as const; const maybeApplyQrRateLimit = async (c: Context, token: string) => { - let ip = 'unknown'; + let ip: string; try { ip = getIpAddress(c.req.raw); @@ -74,6 +72,65 @@ const maybeApplyQrRateLimit = async (c: Context, token: string) => { return rateLimitResponse(c, result); }; +const getEnvelopeItemByToken = async ( + c: Context, + token: string, + envelopeItemId: string, +) => { + const isQrToken = token.startsWith('qr_'); + + if (isQrToken) { + const limited = await maybeApplyQrRateLimit(c, token); + + if (limited) { + return { limited } as const; + } + } + + const envelopeWhereQuery: Prisma.EnvelopeItemWhereUniqueInput = isQrToken + ? { + id: envelopeItemId, + envelope: { + qrToken: token, + status: DocumentStatus.COMPLETED, + }, + } + : { + id: envelopeItemId, + envelope: { + recipients: { + some: { + token, + }, + }, + }, + }; + + const envelopeItem = await prisma.envelopeItem.findUnique({ + where: envelopeWhereQuery, + include: envelopeItemTokenInclude, + }); + + if (!envelopeItem) { + return { error: c.json({ error: 'Envelope item not found' }, 404) } as const; + } + + if (isQrToken && !isPublicDocumentAccessEnabled(envelopeItem.envelope.team)) { + return { + error: c.json( + { error: 'Public completed-document access is disabled for this document' }, + 403, + ), + } as const; + } + + if (!envelopeItem.documentData) { + return { error: c.json({ error: 'Document data not found' }, 404) } as const; + } + + return { envelopeItem, isQrToken } as const; +}; + export const filesRoute = new Hono() /** * Uploads a document file to the appropriate storage location and creates @@ -270,61 +327,21 @@ export const filesRoute = new Hono() sValidator('param', ZGetEnvelopeItemFileTokenRequestParamsSchema), async (c) => { const { token, envelopeItemId } = c.req.valid('param'); - const isQrToken = token.startsWith('qr_'); - if (isQrToken) { - const limited = await maybeApplyQrRateLimit(c, token); + const result = await getEnvelopeItemByToken(c, token, envelopeItemId); - if (limited) { - return limited; - } + if ('limited' in result) { + return result.limited; } - let envelopeWhereQuery: Prisma.EnvelopeItemWhereUniqueInput = { - id: envelopeItemId, - envelope: { - recipients: { - some: { - token, - }, - }, - }, - }; - - if (isQrToken) { - envelopeWhereQuery = { - id: envelopeItemId, - envelope: { - qrToken: token, - status: DocumentStatus.COMPLETED, - }, - }; - } - - const envelopeItem = await prisma.envelopeItem.findUnique({ - where: envelopeWhereQuery, - include: envelopeItemTokenInclude, - }); - - if (!envelopeItem) { - return c.json({ error: 'Envelope item not found' }, 404); - } - - if (isQrToken && !isPublicDocumentAccessEnabled(envelopeItem.envelope.team)) { - return c.json( - { error: 'Public completed-document access is disabled for this document' }, - 403, - ); - } - - if (!envelopeItem.documentData) { - return c.json({ error: 'Document data not found' }, 404); + if ('error' in result) { + return result.error; } return await handleEnvelopeItemFileRequest({ - title: envelopeItem.title, - status: envelopeItem.envelope.status, - documentData: envelopeItem.documentData, + title: result.envelopeItem.title, + status: result.envelopeItem.envelope.status, + documentData: result.envelopeItem.documentData!, version: 'signed', isDownload: false, context: c, @@ -336,62 +353,23 @@ export const filesRoute = new Hono() sValidator('param', ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema), async (c) => { const { token, envelopeItemId, version } = c.req.valid('param'); - const isQrToken = token.startsWith('qr_'); - const effectiveVersion = isQrToken ? 'signed' : version; - if (isQrToken) { - const limited = await maybeApplyQrRateLimit(c, token); + const result = await getEnvelopeItemByToken(c, token, envelopeItemId); - if (limited) { - return limited; - } + if ('limited' in result) { + return result.limited; } - let envelopeWhereQuery: Prisma.EnvelopeItemWhereUniqueInput = { - id: envelopeItemId, - envelope: { - recipients: { - some: { - token, - }, - }, - }, - }; - - if (isQrToken) { - envelopeWhereQuery = { - id: envelopeItemId, - envelope: { - qrToken: token, - status: DocumentStatus.COMPLETED, - }, - }; + if ('error' in result) { + return result.error; } - const envelopeItem = await prisma.envelopeItem.findUnique({ - where: envelopeWhereQuery, - include: envelopeItemTokenInclude, - }); - - if (!envelopeItem) { - return c.json({ error: 'Envelope item not found' }, 404); - } - - if (isQrToken && !isPublicDocumentAccessEnabled(envelopeItem.envelope.team)) { - return c.json( - { error: 'Public completed-document access is disabled for this document' }, - 403, - ); - } - - if (!envelopeItem.documentData) { - return c.json({ error: 'Document data not found' }, 404); - } + const effectiveVersion = result.isQrToken ? 'signed' : version; return await handleEnvelopeItemFileRequest({ - title: envelopeItem.title, - status: envelopeItem.envelope.status, - documentData: envelopeItem.documentData, + title: result.envelopeItem.title, + status: result.envelopeItem.envelope.status, + documentData: result.envelopeItem.documentData!, version: effectiveVersion, isDownload: true, context: c, diff --git a/packages/app-tests/e2e/document-auth/recipient-completed-pdf-share.spec.ts b/packages/app-tests/e2e/document-auth/recipient-completed-pdf-share.spec.ts index 55a506bc3..24590e481 100644 --- a/packages/app-tests/e2e/document-auth/recipient-completed-pdf-share.spec.ts +++ b/packages/app-tests/e2e/document-auth/recipient-completed-pdf-share.spec.ts @@ -8,6 +8,8 @@ import { seedUser } from '@documenso/prisma/seed/users'; import { signSignaturePad } from '../fixtures/signature'; +test.describe.configure({ mode: 'parallel', timeout: 60000 }); + const completeSigningForRecipient = async ({ page, token, @@ -113,7 +115,7 @@ test('[DOCUMENT_AUTH]: recipient cannot access final view action before full com test('[DOCUMENT_AUTH]: invalid QR share token is denied', async ({ page }) => { await page.goto('/share/qr_invalid_token'); - await expect(page.getByRole('heading', { name: 'Unable to open this document' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Unable to Open Document' })).toBeVisible(); await expect(page.getByText('Support code:')).toBeVisible(); }); @@ -186,7 +188,7 @@ test('[DOCUMENT_AUTH]: disabling public completed-document access revokes QR sha }); await page.goto(`/share/${completedEnvelope.qrToken}`); - await expect(page.getByRole('heading', { name: 'Unable to open this document' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Unable to Open Document' })).toBeVisible(); await expect( page.getByText('Public completed-document access is currently disabled.'), ).toBeVisible(); diff --git a/packages/lib/server-only/document/get-document-by-access-token.ts b/packages/lib/server-only/document/get-document-by-access-token.ts index 8e36129e5..5665849b0 100644 --- a/packages/lib/server-only/document/get-document-by-access-token.ts +++ b/packages/lib/server-only/document/get-document-by-access-token.ts @@ -23,7 +23,6 @@ export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTok type: EnvelopeType.DOCUMENT, qrToken: token, }, - // Do not provide extra information that is not needed. select: { id: true, secondaryId: true, @@ -96,10 +95,13 @@ export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTok }); } - const firstDocumentData = result.envelopeItems[0].documentData; + const firstEnvelopeItem = result.envelopeItems[0]; - if (!firstDocumentData) { - throw new Error('Missing document data'); + if (!firstEnvelopeItem?.documentData) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Missing document data for QR token', + statusCode: 404, + }); } return { @@ -109,6 +111,6 @@ export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTok completedAt: result.completedAt, envelopeItems: result.envelopeItems, recipientCount: result._count.recipients, - documentTeamUrl: result.team?.url ?? '', + documentTeamUrl: result.team.url, }; }; diff --git a/packages/prisma/migrations/20260304143000_add_qr_token_index/migration.sql b/packages/prisma/migrations/20260304143000_add_qr_token_index/migration.sql new file mode 100644 index 000000000..4dc09d9fd --- /dev/null +++ b/packages/prisma/migrations/20260304143000_add_qr_token_index/migration.sql @@ -0,0 +1 @@ +CREATE INDEX "Envelope_qrToken_idx" ON "Envelope"("qrToken"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 3eb4e405d..7d2a7df90 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -437,6 +437,7 @@ model Envelope { @@index([teamId]) @@index([folderId]) @@index([createdAt]) + @@index([qrToken]) } model EnvelopeItem { diff --git a/packages/trpc/server/envelope-router/get-envelope-items-by-token.ts b/packages/trpc/server/envelope-router/get-envelope-items-by-token.ts index 9c61cf4c3..b56c757a0 100644 --- a/packages/trpc/server/envelope-router/get-envelope-items-by-token.ts +++ b/packages/trpc/server/envelope-router/get-envelope-items-by-token.ts @@ -1,7 +1,8 @@ -import { EnvelopeType } from '@prisma/client'; +import { DocumentStatus, EnvelopeType } from '@prisma/client'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; +import { isPublicDocumentAccessEnabled } from '@documenso/lib/universal/document-access'; import { prisma } from '@documenso/prisma'; import { maybeAuthenticatedProcedure } from '../trpc'; @@ -62,15 +63,15 @@ const handleGetEnvelopeItemsByToken = async ({ envelopeId: string; token: string; }) => { + const isQrToken = token.startsWith('qr_'); + const envelope = await prisma.envelope.findFirst({ where: { id: envelopeId, - type: EnvelopeType.DOCUMENT, // You cannot get template envelope items by token. - recipients: { - some: { - token, - }, - }, + type: EnvelopeType.DOCUMENT, + ...(isQrToken + ? { qrToken: token, status: DocumentStatus.COMPLETED } + : { recipients: { some: { token } } }), }, include: { envelopeItems: { @@ -78,6 +79,20 @@ const handleGetEnvelopeItemsByToken = async ({ documentData: true, }, }, + team: { + include: { + teamGlobalSettings: { + select: { allowPublicCompletedDocumentAccess: true }, + }, + organisation: { + include: { + organisationGlobalSettings: { + select: { allowPublicCompletedDocumentAccess: true }, + }, + }, + }, + }, + }, }, }); @@ -87,6 +102,12 @@ const handleGetEnvelopeItemsByToken = async ({ }); } + if (isQrToken && !isPublicDocumentAccessEnabled(envelope.team)) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'Public completed-document access is disabled', + }); + } + return { envelopeItems: envelope.envelopeItems, };