diff --git a/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx b/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx index a51389f0b..b51dc5906 100644 --- a/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx +++ b/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx @@ -31,6 +31,8 @@ import { DocumentSigningAuthPageView } from '~/components/general/document-signi import type { Route } from './+types/complete'; +const MAX_QR_RETRY_COUNT = 4; + export async function loader({ params, request }: Route.LoaderArgs) { const { user } = await getOptionalSession(request); @@ -116,13 +118,15 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp ? loaderData.document.status : DocumentStatus.PENDING; - // Poll signing status every few seconds const { data: signingStatusData } = trpc.envelope.signingStatus.useQuery( { token: signingStatusToken, }, { - refetchInterval: 3000, + refetchInterval: (query) => { + const status = query.state.data?.status; + return status === 'COMPLETED' || status === 'REJECTED' ? false : 3000; + }, initialData: match(initialSigningStatus) .with(DocumentStatus.COMPLETED, () => ({ status: 'COMPLETED' }) as const) .with(DocumentStatus.REJECTED, () => ({ status: 'REJECTED' }) as const) @@ -131,9 +135,7 @@ 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); diff --git a/apps/remix/app/routes/_share+/share.$slug.tsx b/apps/remix/app/routes/_share+/share.$slug.tsx index f72d34ac1..fec196642 100644 --- a/apps/remix/app/routes/_share+/share.$slug.tsx +++ b/apps/remix/app/routes/_share+/share.$slug.tsx @@ -7,7 +7,7 @@ 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 { tokenFingerprint } 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'; @@ -124,11 +124,9 @@ 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, + identifier: tokenFingerprint(slug), }); if (rateLimitResult.isLimited) { @@ -190,21 +188,15 @@ export const loader = async ({ request, params: { slug } }: Route.LoaderArgs) => status = 404; code = 'QR_VIEW_NOT_FOUND'; message = 'The shared document could not be found.'; - } - - if (appError.code === AppErrorCode.INVALID_REQUEST || appError.statusCode === 409) { + } else 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) { + } else 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) { + } else if (appError.code === AppErrorCode.UNAUTHORIZED) { status = 401; code = 'QR_VIEW_UNAUTHORIZED'; message = 'You are not authorized to view this document.'; diff --git a/apps/remix/server/api/files/files.ts b/apps/remix/server/api/files/files.ts index 4807e0b5c..b11d1d0a1 100644 --- a/apps/remix/server/api/files/files.ts +++ b/apps/remix/server/api/files/files.ts @@ -8,9 +8,11 @@ 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 { rateLimitResponse } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware'; 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 { tokenFingerprint } from '@documenso/lib/universal/crypto'; +import { isPublicDocumentAccessEnabled } from '@documenso/lib/universal/document-access'; 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'; @@ -29,33 +31,33 @@ import { ZUploadPdfRequestSchema, } from './files.types'; -const isQrSharingEnabledForEnvelopeItem = (envelopeItem: { +const envelopeItemTokenInclude = { envelope: { - team: { - teamGlobalSettings: { - allowPublicCompletedDocumentAccess: boolean | null; - } | null; - organisation: { - organisationGlobalSettings: { - allowPublicCompletedDocumentAccess: boolean; - }; - }; - } | null; - }; -}) => { - const team = envelopeItem.envelope.team; + include: { + team: { + include: { + teamGlobalSettings: { + select: { + allowPublicCompletedDocumentAccess: true, + }, + }, + organisation: { + include: { + organisationGlobalSettings: { + select: { + allowPublicCompletedDocumentAccess: true, + }, + }, + }, + }, + }, + }, + }, + }, + documentData: true, +} as const; - if (!team) { - return true; - } - - return ( - team.teamGlobalSettings?.allowPublicCompletedDocumentAccess ?? - team.organisation.organisationGlobalSettings.allowPublicCompletedDocumentAccess - ); -}; - -const maybeApplyQrRateLimit = async ({ c, token }: { c: Context; token: string }) => { +const maybeApplyQrRateLimit = async (c: Context, token: string) => { let ip = 'unknown'; try { @@ -64,26 +66,12 @@ const maybeApplyQrRateLimit = async ({ c, token }: { c: Context; token: ip = 'unknown'; } - const tokenFingerprint = Buffer.from(sha256(token)).toString('hex').slice(0, 16); const result = await qrShareViewRateLimit.check({ ip, - identifier: tokenFingerprint, + identifier: tokenFingerprint(token), }); - 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); + return rateLimitResponse(c, result); }; export const filesRoute = new Hono() @@ -285,10 +273,10 @@ export const filesRoute = new Hono() const isQrToken = token.startsWith('qr_'); if (isQrToken) { - const rateLimitResponse = await maybeApplyQrRateLimit({ c, token }); + const limited = await maybeApplyQrRateLimit(c, token); - if (rateLimitResponse) { - return rateLimitResponse; + if (limited) { + return limited; } } @@ -315,38 +303,14 @@ export const filesRoute = new Hono() const envelopeItem = await prisma.envelopeItem.findUnique({ where: envelopeWhereQuery, - include: { - envelope: { - include: { - team: { - include: { - teamGlobalSettings: { - select: { - allowPublicCompletedDocumentAccess: true, - }, - }, - organisation: { - include: { - organisationGlobalSettings: { - select: { - allowPublicCompletedDocumentAccess: true, - }, - }, - }, - }, - }, - }, - }, - }, - documentData: true, - }, + include: envelopeItemTokenInclude, }); if (!envelopeItem) { return c.json({ error: 'Envelope item not found' }, 404); } - if (isQrToken && !isQrSharingEnabledForEnvelopeItem(envelopeItem)) { + if (isQrToken && !isPublicDocumentAccessEnabled(envelopeItem.envelope.team)) { return c.json( { error: 'Public completed-document access is disabled for this document' }, 403, @@ -376,10 +340,10 @@ export const filesRoute = new Hono() const effectiveVersion = isQrToken ? 'signed' : version; if (isQrToken) { - const rateLimitResponse = await maybeApplyQrRateLimit({ c, token }); + const limited = await maybeApplyQrRateLimit(c, token); - if (rateLimitResponse) { - return rateLimitResponse; + if (limited) { + return limited; } } @@ -406,38 +370,14 @@ export const filesRoute = new Hono() const envelopeItem = await prisma.envelopeItem.findUnique({ where: envelopeWhereQuery, - include: { - envelope: { - include: { - team: { - include: { - teamGlobalSettings: { - select: { - allowPublicCompletedDocumentAccess: true, - }, - }, - organisation: { - include: { - organisationGlobalSettings: { - select: { - allowPublicCompletedDocumentAccess: true, - }, - }, - }, - }, - }, - }, - }, - }, - documentData: true, - }, + include: envelopeItemTokenInclude, }); if (!envelopeItem) { return c.json({ error: 'Envelope item not found' }, 404); } - if (isQrToken && !isQrSharingEnabledForEnvelopeItem(envelopeItem)) { + if (isQrToken && !isPublicDocumentAccessEnabled(envelopeItem.envelope.team)) { return c.json( { error: 'Public completed-document access is disabled for this document' }, 403, 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 488eeba64..8e36129e5 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,6 +1,7 @@ import { DocumentStatus, EnvelopeType } from '@prisma/client'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { isPublicDocumentAccessEnabled } from '@documenso/lib/universal/document-access'; import { prisma } from '@documenso/prisma'; import { mapSecondaryIdToDocumentId } from '../../utils/envelope'; @@ -88,12 +89,7 @@ export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTok }); } - const allowPublicCompletedDocumentAccess = - result.team?.teamGlobalSettings?.allowPublicCompletedDocumentAccess ?? - result.team?.organisation.organisationGlobalSettings.allowPublicCompletedDocumentAccess ?? - true; - - if (!allowPublicCompletedDocumentAccess) { + if (!isPublicDocumentAccessEnabled(result.team)) { throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Public completed-document access is disabled for this document', statusCode: 403, diff --git a/packages/lib/universal/crypto.ts b/packages/lib/universal/crypto.ts index 671f702b1..7acc045e8 100644 --- a/packages/lib/universal/crypto.ts +++ b/packages/lib/universal/crypto.ts @@ -31,4 +31,8 @@ export const symmetricDecrypt = ({ key, data }: SymmetricDecryptOptions) => { return chacha.decrypt(dataAsBytes); }; +export const tokenFingerprint = (token: string): string => { + return bytesToHex(sha256(token)).slice(0, 16); +}; + export { sha256 }; diff --git a/packages/lib/universal/document-access.ts b/packages/lib/universal/document-access.ts new file mode 100644 index 000000000..81229ac5d --- /dev/null +++ b/packages/lib/universal/document-access.ts @@ -0,0 +1,21 @@ +type PublicAccessTeam = { + teamGlobalSettings?: { + allowPublicCompletedDocumentAccess: boolean | null; + } | null; + organisation: { + organisationGlobalSettings: { + allowPublicCompletedDocumentAccess: boolean; + }; + }; +} | null; + +export const isPublicDocumentAccessEnabled = (team: PublicAccessTeam): boolean => { + if (!team) { + return true; + } + + return ( + team.teamGlobalSettings?.allowPublicCompletedDocumentAccess ?? + team.organisation.organisationGlobalSettings.allowPublicCompletedDocumentAccess + ); +};