Files
documenso/apps/remix/server/api/files/files.helpers.ts
T
Lucas Smith 4f346d3c2d feat: add cancellable document status (#2992)
Adds a CANCELLED envelope status that privileged members (owner or team
admin/manager) can move a pending document into. Sending recipient
notifications via a background job while retaining the document in the
dashboard as proof of distribution.

Includes a dedicated Cancelled tab, single and bulk cancel actions,
the ENVELOPE_CANCELLED mutability guard, and e2e coverage for
permissions
and visibility.
2026-06-18 13:52:35 +10:00

318 lines
8.6 KiB
TypeScript

import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { generatePartialSignedPdf } from '@documenso/lib/server-only/pdf/generate-partial-signed-pdf';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { sha256 } from '@documenso/lib/universal/crypto';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { prisma } from '@documenso/prisma';
import {
type DocumentDataType,
DocumentStatus,
type EnvelopeType,
EnvelopeType as EnvelopeTypeEnum,
type RecipientRole,
type SigningStatus,
type TemplateType,
TemplateType as TemplateTypeEnum,
} from '@prisma/client';
import contentDisposition from 'content-disposition';
import type { Context } from 'hono';
import { match } from 'ts-pattern';
import type { HonoEnv } from '../../router';
type DocumentDataInput = {
type: DocumentDataType;
data: string;
initialData: string;
};
export const resolveFileUploadUserId = async (c: Context<HonoEnv>): Promise<number | null> => {
const session = await getOptionalSession(c);
if (session.user?.id) {
return session.user.id;
}
const authorizationHeader = c.req.header('authorization');
const [bearerToken] = (authorizationHeader || '').split('Bearer ').filter((part) => part.length > 0);
const queryToken = c.req.query('token');
const presignToken = bearerToken || queryToken;
if (!presignToken) {
return null;
}
const verifiedToken = await verifyEmbeddingPresignToken({ token: presignToken }).catch(() => undefined);
return verifiedToken?.userId ?? null;
};
type EnvelopeForPendingDownload = {
id: string;
status: DocumentStatus;
internalVersion: number;
recipients: Array<{
role: RecipientRole;
signingStatus: SigningStatus;
}>;
};
/**
* Options shape varies by `version`:
* - `signed` / `original`: serves stored bytes; only needs envelope `status` for cache headers.
* - `pending`: generates a fresh PDF with currently-inserted fields burned in; needs the
* full envelope (id, status, internalVersion, recipients) plus envelopeItemId to query fields.
*/
type HandleEnvelopeItemFileRequestOptions = {
title: string;
documentData: DocumentDataInput;
isDownload: boolean;
context: Context<HonoEnv>;
} & (
| {
version: 'signed' | 'original';
status: DocumentStatus;
}
| {
version: 'pending';
envelopeItemId: string;
envelope: EnvelopeForPendingDownload;
}
);
/**
* Single entry point for envelope item file requests (view and download).
*
* Dispatches on `version`:
* - `signed` / `original`: returns the stored PDF bytes as-is.
* - `pending`: generates an on-demand PDF with all currently-inserted fields burned in.
*/
export const handleEnvelopeItemFileRequest = async (options: HandleEnvelopeItemFileRequestOptions) => {
if (options.version === 'pending') {
return handlePendingFileRequest(options);
}
return handleStaticFileRequest(options);
};
type StaticFileRequestOptions = Extract<HandleEnvelopeItemFileRequestOptions, { version: 'signed' | 'original' }>;
const handleStaticFileRequest = async ({
title,
status,
documentData,
version,
isDownload,
context: c,
}: StaticFileRequestOptions) => {
const documentDataToUse = version === 'signed' ? documentData.data : documentData.initialData;
const etag = Buffer.from(sha256(documentDataToUse)).toString('hex');
if (c.req.header('If-None-Match') === etag && !isDownload) {
return c.body(null, 304);
}
const file = await getFileServerSide({
type: documentData.type,
data: documentDataToUse,
}).catch((error) => {
console.error(error);
return null;
});
if (!file) {
return c.json({ error: 'File not found' }, 404);
}
c.header('Content-Type', 'application/pdf');
c.header('ETag', etag);
if (!isDownload) {
if (status === DocumentStatus.COMPLETED) {
c.header('Cache-Control', 'public, max-age=31536000, immutable');
} else {
c.header('Cache-Control', 'public, max-age=0, must-revalidate');
}
}
if (isDownload) {
// Generate filename following the pattern from envelope-download-dialog.tsx
const baseTitle = title.replace(/\.pdf$/, '');
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
const filename = `${baseTitle}${suffix}`;
c.header('Content-Disposition', contentDisposition(filename));
// For downloads, prevent caching to ensure fresh data
c.header('Cache-Control', 'no-cache, no-store, must-revalidate');
c.header('Pragma', 'no-cache');
c.header('Expires', '0');
}
return c.body(file);
};
type PendingFileRequestOptions = Extract<HandleEnvelopeItemFileRequestOptions, { version: 'pending' }>;
const handlePendingFileRequest = async ({
title,
envelopeItemId,
envelope,
documentData,
context: c,
}: PendingFileRequestOptions) => {
if (envelope.status !== DocumentStatus.PENDING) {
const errorCode = match(envelope.status)
.with(DocumentStatus.DRAFT, () => AppErrorCode.ENVELOPE_DRAFT)
.with(DocumentStatus.COMPLETED, () => AppErrorCode.ENVELOPE_COMPLETED)
.with(DocumentStatus.REJECTED, () => AppErrorCode.ENVELOPE_REJECTED)
.otherwise(() => AppErrorCode.INVALID_REQUEST);
throw new AppError(errorCode, {
message: `Envelope ${envelope.id} must be pending to download a partially signed PDF`,
statusCode: 400,
});
}
if (envelope.internalVersion !== 2) {
throw new AppError(AppErrorCode.ENVELOPE_LEGACY, {
message: `Envelope ${envelope.id} is a legacy envelope and does not support partially signed PDF downloads`,
statusCode: 400,
});
}
const fields = await prisma.field.findMany({
where: {
envelopeItemId,
inserted: true,
},
include: {
signature: true,
},
orderBy: {
id: 'asc',
},
});
const etag = Buffer.from(
sha256(
JSON.stringify({
envelopeStatus: envelope.status,
fields: fields.map((field) => ({
id: field.id,
customText: field.customText,
signatureId: field.signature?.id ?? null,
signatureCreated: field.signature?.created ?? null,
})),
}),
),
).toString('hex');
if (c.req.header('If-None-Match') === etag) {
c.header('ETag', etag);
c.header('Cache-Control', 'no-store, private');
return c.body(null, 304);
}
const file = await getFileServerSide({
type: documentData.type,
data: documentData.initialData,
}).catch((error) => {
console.error(error);
return null;
});
if (!file) {
return c.json({ error: 'File not found' }, 404);
}
const pdf = await generatePartialSignedPdf({
pdfData: file,
fields,
});
c.get('logger').info({
source: 'pendingPdfDownload',
envelopeId: envelope.id,
envelopeItemId,
insertedFieldCount: fields.length,
etag,
});
c.header('Content-Type', 'application/pdf');
c.header('Cache-Control', 'no-store, private');
c.header('ETag', etag);
const baseTitle = title.replace(/\.pdf$/i, '');
const filename = `${baseTitle}_pending.pdf`;
c.header('Content-Disposition', contentDisposition(filename));
return c.body(pdf);
};
type CheckEnvelopeFileAccessOptions = {
userId: number;
teamId: number;
envelopeType: EnvelopeType;
templateType: TemplateType;
};
/**
* Check whether a user has access to an envelope's file.
*
* First checks team membership. If that fails and the envelope is an
* ORGANISATION template (not a document), falls back to checking whether
* the user belongs to any team in the same organisation.
*/
export const checkEnvelopeFileAccess = async ({
userId,
teamId,
envelopeType,
templateType,
}: CheckEnvelopeFileAccessOptions): Promise<boolean> => {
const team = await getTeamById({ userId, teamId }).catch(() => null);
if (team) {
return true;
}
if (envelopeType === EnvelopeTypeEnum.TEMPLATE && templateType === TemplateTypeEnum.ORGANISATION) {
const orgAccess = await prisma.team.findFirst({
where: {
id: teamId,
organisation: {
teams: {
some: {
teamGroups: {
some: {
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: { userId },
},
},
},
},
},
},
},
},
},
select: { id: true },
});
return orgAccess !== null;
}
return false;
};