diff --git a/.agents/plans/clever-silver-leaf-view-pdf-as-recipient-online-after-completion-via-qr-share-url-outside-team.md b/.agents/plans/clever-silver-leaf-view-pdf-as-recipient-online-after-completion-via-qr-share-url-outside-team.md new file mode 100644 index 000000000..da71fc606 --- /dev/null +++ b/.agents/plans/clever-silver-leaf-view-pdf-as-recipient-online-after-completion-via-qr-share-url-outside-team.md @@ -0,0 +1,199 @@ +--- +date: 2026-03-02 +title: View Pdf As Recipient Online After Completion Via Qr Share Url Outside Team +--- + +## Goal + +Allow a recipient (including users outside the owning team) to open the final completed PDF online from the post-completion experience. + +## Non-Goals + +- No support for historical completed documents that predate this change. +- No recipient access to draft versions, intermediate revisions, or team-internal metadata. +- No rollout flag/canary; ship enabled by default. + +## Current State + +Only sender/team-member paths reliably reach the online PDF viewer. Recipient-side access after signing can fail when authorization assumes team membership. + +## Product Decisions Captured + +- Authorization primitive: signed recipient token (recipient + document scoped). +- URL availability: generate and persist synchronously before showing completion CTA. +- Revocation: inherit existing document share/QR toggle behavior. +- Security controls in v1: access logs plus per-token + IP rate limiting. +- Token binding strictness: recipient + document binding only (no device/IP hard binding). +- Artifact visibility: final completed PDF only. +- Backward compatibility: only new completions are supported. +- Multi-recipient policy: recipient can view only after whole document is fully completed. +- Failure UX: inline retry with backoff and support code. +- Rollout: enabled globally by default. + +## Detailed Plan + +1. Trace and reuse the existing QR/share URL generation source for completed documents. +2. Ensure share URL/token material is created transactionally in the completion finalization flow, before completion CTA rendering. +3. Wire recipient post-completion CTA to this shared URL source (not team-member viewer route assumptions). +4. Add a dedicated authorization path for recipient online-view requests: + - Validate signed token. + - Confirm token recipientId matches a recipient on the target document. + - Confirm token documentId matches request document. + - Confirm document status is fully completed. + - Confirm share/QR access is currently enabled. +5. Keep sender behavior unchanged; sender paths continue through existing sender/team rules. +6. Add fallback UX for missing/failed share URL generation with bounded retry and support code. +7. Add observability and abuse controls (logs, rate limits) on recipient view endpoint. +8. Add and update automated tests for happy path and denial path coverage. + +## Authorization and Security Model + +### Access Contract + +- Recipient view endpoint accepts a signed recipient token (bearer). +- Token claims should include at minimum: + - `documentId` + - `recipientId` + - `completedAt` (or equivalent anti-stale marker) + - `exp` (bounded expiry) +- Team membership is not required for this path. + +### Access Denial Conditions + +- Invalid signature, expired token, or malformed claims. +- Token/document mismatch or token/recipient mismatch. +- Document not fully completed. +- Share/QR feature disabled/revoked for document. +- Rate limit exceeded. + +### Rate Limiting + +- Apply sliding window limits keyed by token fingerprint + source IP. +- Return `429` with `Retry-After` on throttle. +- Log throttle events with reason and correlation id. + +### Audit Logging + +- Log recipient view attempts (allow + deny) with: + - document id + - recipient id (if resolvable) + - result (allow/deny) + - deny reason code + - IP and user-agent + - request correlation id + +## UX and Behavior + +### Post-Completion CTA + +- Completion screen includes `View completed PDF` CTA for recipients. +- CTA is rendered only after synchronous URL generation succeeds. + +### Failure Handling + +- If synchronous generation fails, keep user on completion success context and show: + - clear inline error + - retry action with exponential backoff (bounded attempts) + - support code/correlation id for escalation +- Do not expose internal stack details. + +### Multi-Recipient Behavior + +- Recipient access is blocked until all required recipients are completed and document is in final completed state. + +## Data and Lifecycle + +- Reuse existing share URL persistence model. +- Generate token/share material during completion finalization for new completions only. +- No retroactive migration for previously completed documents. + +## API / Endpoint Expectations + +- Recipient viewer endpoint should return: + - `200` with final PDF viewer payload on success + - `401/403` for token/authz failures (reason mapped to safe frontend message) + - `404` if document is not visible via token context + - `409` if document not yet fully completed + - `429` when rate-limited +- Error responses should expose stable error codes consumable by frontend copy mapping. + +## Testing Strategy + +### Unit / Integration + +- Token validation and claim mismatch rejection. +- Denial when document incomplete. +- Denial when share toggle disabled. +- Rate-limit enforcement behavior. + +### End-to-End + +- Sender path unaffected (regression). +- Recipient outside team can open final PDF after full completion. +- Recipient cannot open before full completion. +- Unauthorized/random user without valid token is denied. +- Failure fallback UI shows retry + support code on forced generation failure. + +## Validation Criteria + +- Recipient can open completed PDF online from completion context without team membership. +- Final-PDF-only visibility is enforced. +- Sender behavior remains unchanged. +- Unauthorized users still cannot access the document. +- Share-toggle revocation immediately removes recipient access. +- Access events and rate-limit events are observable in logs. + +## Risks and Mitigations + +- Risk: broader access than intended. + - Mitigation: strict recipient+document token checks, completed-state check, share-toggle gate. +- Risk: completion-time URL generation increases latency. + - Mitigation: keep generation in bounded transaction path, add retry fallback UX, log latency. +- Risk: support confusion for pre-existing completed documents. + - Mitigation: document "new completions only" behavior in release notes/internal support docs. + +## Implementation Checklist (Repo-Mapped) + +1. Completion UX entry point (recipient side) + - File: `apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx` + - Replace/augment current post-completion action so recipients get a direct `View completed PDF` action that points to `/share/${document.qrToken}` when available. + - Gate visibility on final completion state (`signingStatus === 'COMPLETED'`) and `document.qrToken` presence. + - Keep download and existing sender/home actions unchanged. + +2. Ensure QR token availability at completion time + - File: `packages/lib/jobs/definitions/internal/seal-document.handler.ts` + - Preserve existing `qrToken` creation in sealing flow and confirm it runs before the recipient completion page can surface the final view action. + - If race conditions appear (status completed but `qrToken` missing), add an explicit short polling/fallback state in UI rather than exposing a broken link. + +3. Recipient-readable completed document route + - File: `apps/remix/app/routes/_share+/share.$slug.tsx` + - Keep `qr_` branch as the recipient-safe online view path and ensure it stays independent from team membership checks. + - Keep non-`qr_` slug behavior (social share redirects/meta) unchanged. + +4. Access/read model for QR token + - File: `packages/lib/server-only/document/get-document-by-access-token.ts` + - Maintain strict completed-document-only lookup (`status: COMPLETED`) and minimal selected payload. + - Verify returned payload remains final-artifact-only (no draft/intermediate data leakage). + +5. Existing share-link path boundary (non-goal guardrail) + - Files: `packages/trpc/server/document-router/share-document.ts`, `packages/lib/server-only/share/create-or-get-share-link.ts` + - Do not repurpose social `DocumentShareLink` as authorization source for final PDF access in this change. + - Keep this as a separate concern from QR token completed-document viewing. + +6. Logging and throttling hooks + - Files: `apps/remix/server/router.ts`, `packages/lib/server-only/rate-limit/rate-limit.ts`, `packages/lib/server-only/rate-limit/rate-limit-middleware.ts` + - Add or reuse per-route limits for `/share/qr_*` access attempts. + - Log allow/deny/throttle events with correlation id to support abuse triage. + +7. Test coverage targets + - Add route/component coverage for recipient completion page behavior in `apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx` flows. + - Add integration coverage for QR route access in `apps/remix/app/routes/_share+/share.$slug.tsx` + `packages/lib/server-only/document/get-document-by-access-token.ts`. + - Add E2E scenario under `packages/app-tests/e2e/document-auth/` for: + - recipient outside team sees and uses `View completed PDF` after full completion, + - recipient does not see final-view action before full completion, + - invalid/random QR token is denied. + +8. Verification commands + - Typecheck changed TS packages: `npx tsc --noEmit` + - Run affected tests (targeted): `npm run test:dev -w @documenso/app-tests` + - Optional broader confidence (if needed): `npm run lint` diff --git a/apps/remix/app/components/dialogs/envelope-download-dialog.tsx b/apps/remix/app/components/dialogs/envelope-download-dialog.tsx index 66a291057..0a02a08c9 100644 --- a/apps/remix/app/components/dialogs/envelope-download-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-download-dialog.tsx @@ -67,6 +67,7 @@ export const EnvelopeDownloadDialog = ({ ); const envelopeItems = envelopeItemsPayload?.data || []; + const isQrToken = Boolean(token?.startsWith('qr_')); const onDownload = async ( envelopeItem: EnvelopeItemToDownload, @@ -128,58 +129,53 @@ export const EnvelopeDownloadDialog = ({
{isLoadingEnvelopeItems ? ( - <> - {Array.from({ length: 1 }).map((_, index) => ( -
- +
+ -
- - -
+
+ + +
- -
- ))} - + +
) : ( envelopeItems.map((item) => (
-
- +
+
{/* Todo: Envelopes - Fix overflow */} -

+

{item.title}

-

+

PDF Document

- + {!isQrToken && ( + + )} {envelopeStatus === DocumentStatus.COMPLETED && ( + )} + + {isFullyCompleted && !hasQrToken && ( +
+

+ + We are preparing your online PDF view. If it does not appear, retry below. + +

+ +
+ + Support code: {supportCode} + + + +
+
+ )} + { - if (slug.startsWith('qr_')) { - const document = await getDocumentByAccessToken({ token: slug }); +type TQrShareErrorPayload = { + code: string; + message: string; + correlationId: string; +}; - if (!document) { - throw redirect('/'); +const createQrShareErrorResponse = ({ + status, + code, + message, + correlationId, + headers, +}: { + status: number; + code: string; + message: string; + correlationId: string; + headers?: HeadersInit; +}) => { + return new Response( + JSON.stringify({ + code, + message, + correlationId, + } satisfies TQrShareErrorPayload), + { + status, + headers: { + 'Content-Type': 'application/json', + 'X-Documenso-Error-Code': code, + ...headers, + }, + }, + ); +}; + +const parseQrShareErrorPayload = (value: unknown): TQrShareErrorPayload | null => { + if (!value || typeof value !== 'string') { + return null; + } + + try { + const parsed = JSON.parse(value); + + if ( + typeof parsed.code === 'string' && + typeof parsed.message === 'string' && + typeof parsed.correlationId === 'string' + ) { + return parsed; } - return { - document, - token: slug, - }; + return null; + } catch { + return null; + } +}; + +export const loader = async ({ request, params: { slug } }: Route.LoaderArgs) => { + if (slug.startsWith('qr_')) { + const correlationId = request.headers.get('x-request-id') ?? nanoid(12); + const requestMetadata = extractRequestMetadata(request); + const tokenFingerprint = Buffer.from(sha256(slug)).toString('hex').slice(0, 16); + + const rateLimitResult = await qrShareViewRateLimit.check({ + ip: requestMetadata.ipAddress ?? 'unknown', + identifier: tokenFingerprint, + }); + + if (rateLimitResult.isLimited) { + const retryAfter = String( + Math.max(1, Math.ceil((rateLimitResult.reset.getTime() - Date.now()) / 1000)), + ); + + logger.warn({ + msg: 'QR share access throttled', + documentId: null, + recipientId: null, + result: 'deny', + denyReasonCode: 'QR_VIEW_RATE_LIMITED', + correlationId, + ipAddress: requestMetadata.ipAddress, + userAgent: requestMetadata.userAgent, + }); + + throw createQrShareErrorResponse({ + status: 429, + code: 'QR_VIEW_RATE_LIMITED', + message: 'Too many requests. Please try again shortly.', + correlationId, + headers: { + 'Retry-After': retryAfter, + 'X-RateLimit-Limit': String(rateLimitResult.limit), + 'X-RateLimit-Remaining': String(rateLimitResult.remaining), + 'X-RateLimit-Reset': String(Math.ceil(rateLimitResult.reset.getTime() / 1000)), + }, + }); + } + + try { + const document = await getDocumentByAccessToken({ token: slug }); + + logger.info({ + msg: 'QR share access allowed', + documentId: document.id, + recipientId: null, + result: 'allow', + denyReasonCode: null, + correlationId, + ipAddress: requestMetadata.ipAddress, + userAgent: requestMetadata.userAgent, + }); + + return { + document, + token: slug, + }; + } catch (error) { + const appError = AppError.parseError(error); + + let status = 500; + let code = 'QR_VIEW_INTERNAL_ERROR'; + let message = 'An unexpected error occurred while opening this document.'; + + if (appError.code === AppErrorCode.NOT_FOUND) { + status = 404; + code = 'QR_VIEW_NOT_FOUND'; + message = 'The shared document could not be found.'; + } + + if (appError.code === AppErrorCode.INVALID_REQUEST || appError.statusCode === 409) { + status = 409; + code = 'QR_VIEW_NOT_COMPLETED'; + message = 'This document is not fully completed yet.'; + } + + if (appError.code === AppErrorCode.UNAUTHORIZED && appError.statusCode === 403) { + status = 403; + code = 'QR_VIEW_DISABLED'; + message = 'Public completed-document access is currently disabled.'; + } + + if (appError.code === AppErrorCode.UNAUTHORIZED && appError.statusCode !== 403) { + status = 401; + code = 'QR_VIEW_UNAUTHORIZED'; + message = 'You are not authorized to view this document.'; + } + + logger.warn({ + msg: 'QR share access denied', + documentId: null, + recipientId: null, + result: 'deny', + denyReasonCode: code, + correlationId, + ipAddress: requestMetadata.ipAddress, + userAgent: requestMetadata.userAgent, + }); + + throw createQrShareErrorResponse({ + status, + code, + message, + correlationId, + }); + } } const userAgent = request.headers.get('User-Agent') ?? ''; @@ -94,3 +260,39 @@ export default function SharePage() { return
; } + +export function ErrorBoundary() { + const error = useRouteError(); + const payload = isRouteErrorResponse(error) ? parseQrShareErrorPayload(error.data) : null; + + return ( +
+
+ + +
+

+ Unable to Open Document +

+

+ {payload?.message ?? ( + Something went wrong while opening this shared view. + )} +

+ + {payload?.correlationId && ( +

+ Support code: {payload.correlationId} +

+ )} + + +
+
+
+ ); +} diff --git a/apps/remix/server/api/files/files.ts b/apps/remix/server/api/files/files.ts index 18db5d959..4807e0b5c 100644 --- a/apps/remix/server/api/files/files.ts +++ b/apps/remix/server/api/files/files.ts @@ -1,12 +1,17 @@ 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 { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token'; +import { qrShareViewRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits'; import { getTeamById } from '@documenso/lib/server-only/team/get-team'; +import { sha256 } from '@documenso/lib/universal/crypto'; +import { getIpAddress } from '@documenso/lib/universal/get-ip-address'; import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; import { prisma } from '@documenso/prisma'; @@ -24,6 +29,63 @@ import { ZUploadPdfRequestSchema, } from './files.types'; +const isQrSharingEnabledForEnvelopeItem = (envelopeItem: { + envelope: { + team: { + teamGlobalSettings: { + allowPublicCompletedDocumentAccess: boolean | null; + } | null; + organisation: { + organisationGlobalSettings: { + allowPublicCompletedDocumentAccess: boolean; + }; + }; + } | null; + }; +}) => { + const team = envelopeItem.envelope.team; + + if (!team) { + return true; + } + + return ( + team.teamGlobalSettings?.allowPublicCompletedDocumentAccess ?? + team.organisation.organisationGlobalSettings.allowPublicCompletedDocumentAccess + ); +}; + +const maybeApplyQrRateLimit = async ({ c, token }: { c: Context; token: string }) => { + let ip = 'unknown'; + + try { + ip = getIpAddress(c.req.raw); + } catch { + ip = 'unknown'; + } + + const tokenFingerprint = Buffer.from(sha256(token)).toString('hex').slice(0, 16); + const result = await qrShareViewRateLimit.check({ + ip, + identifier: tokenFingerprint, + }); + + c.header('X-RateLimit-Limit', String(result.limit)); + c.header('X-RateLimit-Remaining', String(result.remaining)); + c.header('X-RateLimit-Reset', String(Math.ceil(result.reset.getTime() / 1000))); + + if (!result.isLimited) { + return null; + } + + c.header( + 'Retry-After', + String(Math.max(1, Math.ceil((result.reset.getTime() - Date.now()) / 1000))), + ); + + return c.json({ error: 'Too many requests, please try again later.' }, 429); +}; + export const filesRoute = new Hono() /** * Uploads a document file to the appropriate storage location and creates @@ -220,6 +282,15 @@ 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 rateLimitResponse = await maybeApplyQrRateLimit({ c, token }); + + if (rateLimitResponse) { + return rateLimitResponse; + } + } let envelopeWhereQuery: Prisma.EnvelopeItemWhereUniqueInput = { id: envelopeItemId, @@ -232,11 +303,12 @@ export const filesRoute = new Hono() }, }; - if (token.startsWith('qr_')) { + if (isQrToken) { envelopeWhereQuery = { id: envelopeItemId, envelope: { qrToken: token, + status: DocumentStatus.COMPLETED, }, }; } @@ -244,7 +316,28 @@ export const filesRoute = new Hono() const envelopeItem = await prisma.envelopeItem.findUnique({ where: envelopeWhereQuery, include: { - envelope: true, + envelope: { + include: { + team: { + include: { + teamGlobalSettings: { + select: { + allowPublicCompletedDocumentAccess: true, + }, + }, + organisation: { + include: { + organisationGlobalSettings: { + select: { + allowPublicCompletedDocumentAccess: true, + }, + }, + }, + }, + }, + }, + }, + }, documentData: true, }, }); @@ -253,6 +346,13 @@ export const filesRoute = new Hono() return c.json({ error: 'Envelope item not found' }, 404); } + if (isQrToken && !isQrSharingEnabledForEnvelopeItem(envelopeItem)) { + 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); } @@ -272,6 +372,16 @@ 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 rateLimitResponse = await maybeApplyQrRateLimit({ c, token }); + + if (rateLimitResponse) { + return rateLimitResponse; + } + } let envelopeWhereQuery: Prisma.EnvelopeItemWhereUniqueInput = { id: envelopeItemId, @@ -284,11 +394,12 @@ export const filesRoute = new Hono() }, }; - if (token.startsWith('qr_')) { + if (isQrToken) { envelopeWhereQuery = { id: envelopeItemId, envelope: { qrToken: token, + status: DocumentStatus.COMPLETED, }, }; } @@ -296,7 +407,28 @@ export const filesRoute = new Hono() const envelopeItem = await prisma.envelopeItem.findUnique({ where: envelopeWhereQuery, include: { - envelope: true, + envelope: { + include: { + team: { + include: { + teamGlobalSettings: { + select: { + allowPublicCompletedDocumentAccess: true, + }, + }, + organisation: { + include: { + organisationGlobalSettings: { + select: { + allowPublicCompletedDocumentAccess: true, + }, + }, + }, + }, + }, + }, + }, + }, documentData: true, }, }); @@ -305,6 +437,13 @@ export const filesRoute = new Hono() return c.json({ error: 'Envelope item not found' }, 404); } + if (isQrToken && !isQrSharingEnabledForEnvelopeItem(envelopeItem)) { + 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); } @@ -313,7 +452,7 @@ export const filesRoute = new Hono() title: envelopeItem.title, status: envelopeItem.envelope.status, documentData: envelopeItem.documentData, - version, + 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 new file mode 100644 index 000000000..55a506bc3 --- /dev/null +++ b/packages/app-tests/e2e/document-auth/recipient-completed-pdf-share.spec.ts @@ -0,0 +1,193 @@ +import { expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { DocumentStatus, FieldType } from '@prisma/client'; + +import { prisma } from '@documenso/prisma'; +import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { signSignaturePad } from '../fixtures/signature'; + +const completeSigningForRecipient = async ({ + page, + token, + fields, +}: { + page: Page; + token: string; + fields: { id: number; type: FieldType }[]; +}) => { + const signUrl = `/sign/${token}`; + + await page.goto(signUrl); + await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); + + await signSignaturePad(page); + + for (const field of fields) { + await page.locator(`#field-${field.id}`).getByRole('button').click(); + + if (field.type === FieldType.TEXT) { + await page.locator('#custom-text').fill('TEXT'); + await page.getByRole('button', { name: 'Save' }).click(); + } + + await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true'); + } + + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(`${signUrl}/complete`); +}; + +test('[DOCUMENT_AUTH]: recipient outside team can open completed PDF via QR share route', async ({ + page, +}) => { + const { user: owner, team } = await seedUser(); + const { user: outsideRecipientA } = await seedUser(); + const { user: outsideRecipientB } = await seedUser(); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner, + teamId: team.id, + recipients: [outsideRecipientA, outsideRecipientB], + fields: [FieldType.SIGNATURE], + }); + + const firstRecipient = recipients[0]; + const secondRecipient = recipients[1]; + + await completeSigningForRecipient({ + page, + token: firstRecipient.token, + fields: firstRecipient.fields, + }); + + await expect(page.getByRole('link', { name: 'View completed PDF' })).not.toBeVisible(); + + await completeSigningForRecipient({ + page, + token: secondRecipient.token, + fields: secondRecipient.fields, + }); + + await expect(async () => { + await page.reload(); + await expect(page.getByRole('link', { name: 'View completed PDF' })).toBeVisible(); + }).toPass({ + timeout: 30000, + intervals: [1000, 2000, 3000], + }); + + await page.getByRole('link', { name: 'View completed PDF' }).click(); + await expect(page).toHaveURL(/\/share\/qr_/); + await expect(page.getByRole('button', { name: 'Download' })).toBeVisible(); +}); + +test('[DOCUMENT_AUTH]: recipient cannot access final view action before full completion', async ({ + page, +}) => { + const { user: owner, team } = await seedUser(); + const { user: outsideRecipientA } = await seedUser(); + const { user: outsideRecipientB } = await seedUser(); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner, + teamId: team.id, + recipients: [outsideRecipientA, outsideRecipientB], + fields: [FieldType.SIGNATURE], + }); + + const firstRecipient = recipients[0]; + + await completeSigningForRecipient({ + page, + token: firstRecipient.token, + fields: firstRecipient.fields, + }); + + await expect(page.getByText('Waiting for others to sign')).toBeVisible(); + await expect(page.getByRole('link', { name: 'View completed PDF' })).not.toBeVisible(); +}); + +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.getByText('Support code:')).toBeVisible(); +}); + +test('[DOCUMENT_AUTH]: disabling public completed-document access revokes QR share access immediately', async ({ + page, +}) => { + const { user: owner, team } = await seedUser(); + const { user: outsideRecipientA } = await seedUser(); + const { user: outsideRecipientB } = await seedUser(); + + const { document, recipients } = await seedPendingDocumentWithFullFields({ + owner, + teamId: team.id, + recipients: [outsideRecipientA, outsideRecipientB], + fields: [FieldType.SIGNATURE], + }); + + await completeSigningForRecipient({ + page, + token: recipients[0].token, + fields: recipients[0].fields, + }); + + await completeSigningForRecipient({ + page, + token: recipients[1].token, + fields: recipients[1].fields, + }); + + await expect(async () => { + const envelope = await prisma.envelope.findFirstOrThrow({ + where: { + id: document.id, + }, + select: { + id: true, + status: true, + qrToken: true, + }, + }); + + expect(envelope.status).toBe(DocumentStatus.COMPLETED); + expect(envelope.qrToken).toBeTruthy(); + }).toPass(); + + const completedEnvelope = await prisma.envelope.findFirstOrThrow({ + where: { + id: document.id, + }, + select: { + qrToken: true, + }, + }); + + const teamSettings = await prisma.teamGlobalSettings.findFirstOrThrow({ + where: { + team: { + id: team.id, + }, + }, + }); + + await prisma.teamGlobalSettings.update({ + where: { + id: teamSettings.id, + }, + data: { + allowPublicCompletedDocumentAccess: false, + }, + }); + + await page.goto(`/share/${completedEnvelope.qrToken}`); + await expect(page.getByRole('heading', { name: 'Unable to open this 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 444d7d678..488eeba64 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 @@ -1,5 +1,6 @@ import { DocumentStatus, EnvelopeType } from '@prisma/client'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { prisma } from '@documenso/prisma'; import { mapSecondaryIdToDocumentId } from '../../utils/envelope'; @@ -10,25 +11,42 @@ export type GetDocumentByAccessTokenOptions = { export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTokenOptions) => { if (!token) { - throw new Error('Missing token'); + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'Missing QR access token', + statusCode: 401, + }); } - const result = await prisma.envelope.findFirstOrThrow({ + const result = await prisma.envelope.findFirst({ where: { type: EnvelopeType.DOCUMENT, - status: DocumentStatus.COMPLETED, qrToken: token, }, // Do not provide extra information that is not needed. select: { id: true, secondaryId: true, + status: true, internalVersion: true, title: true, completedAt: true, team: { select: { url: true, + organisation: { + select: { + organisationGlobalSettings: { + select: { + allowPublicCompletedDocumentAccess: true, + }, + }, + }, + }, + teamGlobalSettings: { + select: { + allowPublicCompletedDocumentAccess: true, + }, + }, }, }, envelopeItems: { @@ -56,6 +74,32 @@ export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTok }, }); + if (!result) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'QR token not found', + statusCode: 404, + }); + } + + if (result.status !== DocumentStatus.COMPLETED) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Document is not fully completed', + statusCode: 409, + }); + } + + const allowPublicCompletedDocumentAccess = + result.team?.teamGlobalSettings?.allowPublicCompletedDocumentAccess ?? + result.team?.organisation.organisationGlobalSettings.allowPublicCompletedDocumentAccess ?? + true; + + if (!allowPublicCompletedDocumentAccess) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'Public completed-document access is disabled for this document', + statusCode: 403, + }); + } + const firstDocumentData = result.envelopeItems[0].documentData; if (!firstDocumentData) { @@ -69,6 +113,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/lib/server-only/rate-limit/rate-limits.ts b/packages/lib/server-only/rate-limit/rate-limits.ts index 25069a3bf..082a4ca6f 100644 --- a/packages/lib/server-only/rate-limit/rate-limits.ts +++ b/packages/lib/server-only/rate-limit/rate-limits.ts @@ -97,3 +97,10 @@ export const fileUploadRateLimit = createRateLimit({ max: 20, window: '1m', }); + +export const qrShareViewRateLimit = createRateLimit({ + action: 'app.qr-share-view', + max: 20, + globalMax: 120, + window: '1m', +}); diff --git a/packages/lib/utils/organisations.ts b/packages/lib/utils/organisations.ts index b38e9a697..42428e2e3 100644 --- a/packages/lib/utils/organisations.ts +++ b/packages/lib/utils/organisations.ts @@ -122,6 +122,7 @@ export const generateDefaultOrganisationSettings = (): Omit< includeSenderDetails: true, includeSigningCertificate: true, + allowPublicCompletedDocumentAccess: true, includeAuditLog: false, typedSignatureEnabled: true, diff --git a/packages/lib/utils/teams.ts b/packages/lib/utils/teams.ts index 02fc562c1..ac2023e18 100644 --- a/packages/lib/utils/teams.ts +++ b/packages/lib/utils/teams.ts @@ -188,6 +188,7 @@ export const generateDefaultTeamSettings = (): Omit