mirror of
https://github.com/documenso/documenso.git
synced 2026-07-14 14:57:12 +10:00
refactor: deduplicate QR share helpers and fix polling leak
Extract shared tokenFingerprint() and isPublicDocumentAccessEnabled() helpers, reuse rateLimitResponse from rate-limit-middleware, deduplicate Prisma include across view/download handlers, convert error if-chain to if/else-if, move MAX_QR_RETRY_COUNT to module scope, and stop refetchInterval once document reaches terminal status.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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<HonoEnv>; token: string }) => {
|
||||
const maybeApplyQrRateLimit = async (c: Context<HonoEnv>, token: string) => {
|
||||
let ip = 'unknown';
|
||||
|
||||
try {
|
||||
@@ -64,26 +66,12 @@ const maybeApplyQrRateLimit = async ({ c, token }: { c: Context<HonoEnv>; 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<HonoEnv>()
|
||||
@@ -285,10 +273,10 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
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<HonoEnv>()
|
||||
|
||||
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<HonoEnv>()
|
||||
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<HonoEnv>()
|
||||
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user