mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 01:15:49 +10:00
feat: allow recipients to view completed PDF via QR share link
Add allowPublicCompletedDocumentAccess toggle at org/team level (team inherits from org via null). Recipients see a "View completed PDF" button on the signing completion page that links to /share/qr_*. - DB migration adding toggle to OrganisationGlobalSettings and TeamGlobalSettings - Settings UI for org and team document preferences - Rate limiting on QR share view and file download endpoints - Structured error responses with support codes in share route ErrorBoundary - Exponential backoff retry when qrToken not yet available post-completion - QR-authenticated viewers restricted to signed PDF only (no original) - E2E tests covering happy path, not-yet-completed, invalid token, and toggle revocation
This commit is contained in:
@@ -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 = ({
|
||||
|
||||
<div className="flex w-full flex-col gap-4 overflow-hidden">
|
||||
{isLoadingEnvelopeItems ? (
|
||||
<>
|
||||
{Array.from({ length: 1 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border-border bg-card flex items-center gap-2 rounded-lg border p-4"
|
||||
>
|
||||
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-lg" />
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-card p-4">
|
||||
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-lg" />
|
||||
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Skeleton className="h-4 w-28 rounded-lg" />
|
||||
<Skeleton className="h-4 w-20 rounded-lg" />
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Skeleton className="h-4 w-28 rounded-lg" />
|
||||
<Skeleton className="h-4 w-20 rounded-lg" />
|
||||
</div>
|
||||
|
||||
<Skeleton className="h-10 w-20 flex-shrink-0 rounded-lg" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
<Skeleton className="h-10 w-20 flex-shrink-0 rounded-lg" />
|
||||
</div>
|
||||
) : (
|
||||
envelopeItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="border-border bg-card hover:bg-accent/50 flex items-center gap-4 rounded-lg border p-4 transition-colors"
|
||||
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||
<FileTextIcon className="text-primary h-5 w-5" />
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<FileTextIcon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Todo: Envelopes - Fix overflow */}
|
||||
<h4 className="text-foreground truncate text-sm font-medium" title={item.title}>
|
||||
<h4 className="truncate text-sm font-medium text-foreground" title={item.title}>
|
||||
{item.title}
|
||||
</h4>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
<Trans>PDF Document</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={async () => onDownload(item, 'original')}
|
||||
loading={isDownloadingState[generateDownloadKey(item.id, 'original')]}
|
||||
>
|
||||
{!isDownloadingState[generateDownloadKey(item.id, 'original')] && (
|
||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<Trans context="Original document (adjective)">Original</Trans>
|
||||
</Button>
|
||||
{!isQrToken && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={async () => onDownload(item, 'original')}
|
||||
loading={isDownloadingState[generateDownloadKey(item.id, 'original')]}
|
||||
>
|
||||
{!isDownloadingState[generateDownloadKey(item.id, 'original')] && (
|
||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<Trans context="Original document (adjective)">Original</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{envelopeStatus === DocumentStatus.COMPLETED && (
|
||||
<Button
|
||||
|
||||
@@ -70,6 +70,7 @@ export type TDocumentPreferencesFormSchema = {
|
||||
documentDateFormat: TDocumentMetaDateFormat | null;
|
||||
includeSenderDetails: boolean | null;
|
||||
includeSigningCertificate: boolean | null;
|
||||
allowPublicCompletedDocumentAccess: boolean | null;
|
||||
includeAuditLog: boolean | null;
|
||||
signatureTypes: DocumentSignatureType[];
|
||||
defaultRecipients: TDefaultRecipients | null;
|
||||
@@ -86,6 +87,7 @@ type SettingsSubset = Pick<
|
||||
| 'documentDateFormat'
|
||||
| 'includeSenderDetails'
|
||||
| 'includeSigningCertificate'
|
||||
| 'allowPublicCompletedDocumentAccess'
|
||||
| 'includeAuditLog'
|
||||
| 'typedSignatureEnabled'
|
||||
| 'uploadSignatureEnabled'
|
||||
@@ -126,6 +128,7 @@ export const DocumentPreferencesForm = ({
|
||||
documentDateFormat: ZDocumentMetaTimezoneSchema.nullable(),
|
||||
includeSenderDetails: z.boolean().nullable(),
|
||||
includeSigningCertificate: z.boolean().nullable(),
|
||||
allowPublicCompletedDocumentAccess: z.boolean().nullable(),
|
||||
includeAuditLog: z.boolean().nullable(),
|
||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
|
||||
message: msg`At least one signature type must be enabled`.id,
|
||||
@@ -147,6 +150,7 @@ export const DocumentPreferencesForm = ({
|
||||
documentDateFormat: settings.documentDateFormat as TDocumentMetaDateFormat | null,
|
||||
includeSenderDetails: settings.includeSenderDetails,
|
||||
includeSigningCertificate: settings.includeSigningCertificate,
|
||||
allowPublicCompletedDocumentAccess: settings.allowPublicCompletedDocumentAccess,
|
||||
includeAuditLog: settings.includeAuditLog,
|
||||
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
||||
defaultRecipients: settings.defaultRecipients
|
||||
@@ -494,6 +498,58 @@ export const DocumentPreferencesForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allowPublicCompletedDocumentAccess"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Allow Public Access to Completed Documents via QR/Share Link</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value === null ? '-1' : field.value.toString()}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="bg-background text-muted-foreground"
|
||||
data-testid="allow-public-completed-document-access-trigger"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="true">
|
||||
<Trans>Yes</Trans>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value="false">
|
||||
<Trans>No</Trans>
|
||||
</SelectItem>
|
||||
|
||||
{canInherit && (
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>Inherit from organisation</Trans>
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls whether recipients can open completed documents online through QR/share
|
||||
links, including recipients outside your team.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includeAuditLog"
|
||||
|
||||
@@ -61,6 +61,10 @@ export const EnvelopeRendererFileSelector = ({
|
||||
}: EnvelopeRendererFileSelectorProps) => {
|
||||
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
if (envelopeItems.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-fit flex-shrink-0 space-x-2 overflow-x-auto p-4', className)}>
|
||||
{envelopeItems.map((doc, i) => (
|
||||
|
||||
@@ -55,6 +55,7 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
allowPublicCompletedDocumentAccess,
|
||||
includeAuditLog,
|
||||
signatureTypes,
|
||||
defaultRecipients,
|
||||
@@ -69,6 +70,7 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
documentDateFormat === null ||
|
||||
includeSenderDetails === null ||
|
||||
includeSigningCertificate === null ||
|
||||
allowPublicCompletedDocumentAccess === null ||
|
||||
includeAuditLog === null ||
|
||||
aiFeaturesEnabled === null
|
||||
) {
|
||||
@@ -84,6 +86,7 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
allowPublicCompletedDocumentAccess,
|
||||
includeAuditLog,
|
||||
defaultRecipients,
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
|
||||
@@ -48,6 +48,7 @@ export default function TeamsSettingsPage() {
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
allowPublicCompletedDocumentAccess,
|
||||
includeAuditLog,
|
||||
signatureTypes,
|
||||
defaultRecipients,
|
||||
@@ -65,6 +66,7 @@ export default function TeamsSettingsPage() {
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
allowPublicCompletedDocumentAccess,
|
||||
includeAuditLog,
|
||||
defaultRecipients,
|
||||
aiFeaturesEnabled,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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, Loader2 } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
import { CheckCircle2, Clock8, DownloadIcon, EyeIcon, Loader2 } from 'lucide-react';
|
||||
import { Link, useRevalidator } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
||||
@@ -103,29 +105,25 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
|
||||
export default function CompletedSigningPage({ loaderData }: Route.ComponentProps) {
|
||||
const { _ } = useLingui();
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
const { sessionData } = useOptionalSession();
|
||||
const user = sessionData?.user;
|
||||
|
||||
const {
|
||||
isDocumentAccessValid,
|
||||
canSignUp,
|
||||
recipientName,
|
||||
signatures,
|
||||
document,
|
||||
recipient,
|
||||
recipientEmail,
|
||||
returnToHomePath,
|
||||
} = loaderData;
|
||||
const { isDocumentAccessValid, recipientEmail } = loaderData;
|
||||
const signingStatusToken = isDocumentAccessValid ? loaderData.recipient.token : '';
|
||||
const initialSigningStatus = isDocumentAccessValid
|
||||
? loaderData.document.status
|
||||
: DocumentStatus.PENDING;
|
||||
|
||||
// Poll signing status every few seconds
|
||||
const { data: signingStatusData } = trpc.envelope.signingStatus.useQuery(
|
||||
{
|
||||
token: recipient?.token || '',
|
||||
token: signingStatusToken,
|
||||
},
|
||||
{
|
||||
refetchInterval: 3000,
|
||||
initialData: match(document?.status)
|
||||
initialData: match(initialSigningStatus)
|
||||
.with(DocumentStatus.COMPLETED, () => ({ status: 'COMPLETED' }) as const)
|
||||
.with(DocumentStatus.REJECTED, () => ({ status: 'REJECTED' }) as const)
|
||||
.with(DocumentStatus.PENDING, () => ({ status: 'PENDING' }) as const)
|
||||
@@ -135,11 +133,61 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
|
||||
|
||||
// Use signing status from query if available, otherwise fall back to document status
|
||||
const signingStatus = signingStatusData?.status ?? 'PENDING';
|
||||
const MAX_QR_RETRY_COUNT = 4;
|
||||
|
||||
const [qrRetryCount, setQrRetryCount] = useState(0);
|
||||
const [isRetryingQrLink, setIsRetryingQrLink] = useState(false);
|
||||
|
||||
const onRetryQrLink = useCallback(async () => {
|
||||
if (!isDocumentAccessValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (qrRetryCount >= MAX_QR_RETRY_COUNT) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRetryingQrLink(true);
|
||||
|
||||
const nextRetryCount = qrRetryCount + 1;
|
||||
const retryDelay = Math.min(250 * 2 ** nextRetryCount, 3000);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, retryDelay);
|
||||
});
|
||||
|
||||
setQrRetryCount(nextRetryCount);
|
||||
await revalidator.revalidate();
|
||||
setIsRetryingQrLink(false);
|
||||
}, [isDocumentAccessValid, qrRetryCount, revalidator]);
|
||||
|
||||
const isFullyCompleted = isDocumentAccessValid && signingStatus === 'COMPLETED';
|
||||
const hasQrToken = isDocumentAccessValid && Boolean(loaderData.document.qrToken);
|
||||
const supportCode = isDocumentAccessValid
|
||||
? `QR-${loaderData.document.id}-${loaderData.recipient.id}`
|
||||
: '';
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isFullyCompleted ||
|
||||
hasQrToken ||
|
||||
isRetryingQrLink ||
|
||||
revalidator.state !== 'idle' ||
|
||||
qrRetryCount >= MAX_QR_RETRY_COUNT
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
void onRetryQrLink();
|
||||
}, [hasQrToken, isFullyCompleted, isRetryingQrLink, onRetryQrLink, qrRetryCount, revalidator]);
|
||||
|
||||
if (!isDocumentAccessValid) {
|
||||
return <DocumentSigningAuthPageView email={recipientEmail} />;
|
||||
}
|
||||
|
||||
const { canSignUp, recipientName, signatures, document, recipient, returnToHomePath } =
|
||||
loaderData;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -244,6 +292,42 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
|
||||
))}
|
||||
|
||||
<div className="mt-8 flex w-full max-w-xs flex-col items-stretch gap-4 md:w-auto md:max-w-none md:flex-row md:items-center">
|
||||
{isFullyCompleted && hasQrToken && (
|
||||
<Button asChild variant="secondary" className="w-full">
|
||||
<Link to={`/share/${document.qrToken}`}>
|
||||
<EyeIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>View completed PDF</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isFullyCompleted && !hasQrToken && (
|
||||
<div className="w-full rounded-md border border-orange-200 bg-orange-50 p-3 text-left text-sm text-orange-900 md:max-w-sm">
|
||||
<p>
|
||||
<Trans>
|
||||
We are preparing your online PDF view. If it does not appear, retry below.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-orange-700">
|
||||
<Trans>Support code</Trans>: {supportCode}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={isRetryingQrLink || revalidator.state === 'loading'}
|
||||
disabled={qrRetryCount >= MAX_QR_RETRY_COUNT}
|
||||
onClick={() => void onRetryQrLink()}
|
||||
>
|
||||
<Trans>Retry</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DocumentShareButton
|
||||
documentId={document.id}
|
||||
token={recipient.token}
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import { redirect, useLoaderData } from 'react-router';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { Link, isRouteErrorResponse, redirect, useLoaderData, useRouteError } from 'react-router';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getDocumentByAccessToken } from '@documenso/lib/server-only/document/get-document-by-access-token';
|
||||
import { qrShareViewRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
|
||||
import { sha256 } from '@documenso/lib/universal/crypto';
|
||||
import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { logger } from '@documenso/lib/utils/logger';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { DocumentCertificateQRView } from '~/components/general/document/document-certificate-qr-view';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
import type { Route } from './+types/share.$slug';
|
||||
|
||||
export function meta({ params: { slug } }: Route.MetaArgs) {
|
||||
export function meta({ params: { slug }, loaderData }: Route.MetaArgs) {
|
||||
if (slug.startsWith('qr_')) {
|
||||
return undefined;
|
||||
const documentTitle = loaderData?.document?.title ?? 'Shared Document';
|
||||
|
||||
return [...appMetaTags(documentTitle), { name: 'robots', content: 'noindex, nofollow' }];
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -50,18 +62,172 @@ export function meta({ params: { slug } }: Route.MetaArgs) {
|
||||
];
|
||||
}
|
||||
|
||||
export const loader = async ({ request, params: { slug } }: Route.LoaderArgs) => {
|
||||
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 <div></div>;
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
const payload = isRouteErrorResponse(error) ? parseQrShareErrorPayload(error.data) : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<AlertCircle className="size-10 self-start text-destructive" />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||
<Trans>Unable to Open Document</Trans>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{payload?.message ?? (
|
||||
<Trans>Something went wrong while opening this shared view.</Trans>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{payload?.correlationId && (
|
||||
<p className="mt-4 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<Trans>Support code: {payload.correlationId}</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button className="mt-6 w-fit" asChild>
|
||||
<Link to="/">
|
||||
<Trans>Return Home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HonoEnv>; 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<HonoEnv>()
|
||||
/**
|
||||
* Uploads a document file to the appropriate storage location and creates
|
||||
@@ -220,6 +282,15 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
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<HonoEnv>()
|
||||
},
|
||||
};
|
||||
|
||||
if (token.startsWith('qr_')) {
|
||||
if (isQrToken) {
|
||||
envelopeWhereQuery = {
|
||||
id: envelopeItemId,
|
||||
envelope: {
|
||||
qrToken: token,
|
||||
status: DocumentStatus.COMPLETED,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -244,7 +316,28 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
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<HonoEnv>()
|
||||
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<HonoEnv>()
|
||||
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<HonoEnv>()
|
||||
},
|
||||
};
|
||||
|
||||
if (token.startsWith('qr_')) {
|
||||
if (isQrToken) {
|
||||
envelopeWhereQuery = {
|
||||
id: envelopeItemId,
|
||||
envelope: {
|
||||
qrToken: token,
|
||||
status: DocumentStatus.COMPLETED,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -296,7 +407,28 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
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<HonoEnv>()
|
||||
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<HonoEnv>()
|
||||
title: envelopeItem.title,
|
||||
status: envelopeItem.envelope.status,
|
||||
documentData: envelopeItem.documentData,
|
||||
version,
|
||||
version: effectiveVersion,
|
||||
isDownload: true,
|
||||
context: c,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user