Merge branch 'main' into feat/public-completed-document-access

This commit is contained in:
ephraimduncan
2026-04-22 18:29:11 +00:00
625 changed files with 48707 additions and 14739 deletions
+69 -1
View File
@@ -1,9 +1,17 @@
import { type DocumentDataType, DocumentStatus } from '@prisma/client';
import {
type DocumentDataType,
DocumentStatus,
type EnvelopeType,
type TemplateType,
} from '@prisma/client';
import { EnvelopeType as EnvelopeTypeEnum, TemplateType as TemplateTypeEnum } from '@prisma/client';
import contentDisposition from 'content-disposition';
import { type Context } from 'hono';
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 { HonoEnv } from '../../router';
@@ -79,3 +87,63 @@ export const handleEnvelopeItemFileRequest = async ({
return c.body(file);
};
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;
};
+17 -15
View File
@@ -8,7 +8,6 @@ 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 { tokenFingerprint } from '@documenso/lib/universal/crypto';
import { isPublicDocumentAccessEnabled } from '@documenso/lib/universal/document-access';
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
@@ -17,7 +16,7 @@ import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-action
import { prisma } from '@documenso/prisma';
import type { HonoEnv } from '../../router';
import { handleEnvelopeItemFileRequest } from './files.helpers';
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest } from './files.helpers';
import {
type TGetPresignedPostUrlResponse,
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
@@ -28,6 +27,8 @@ import {
ZGetPresignedPostUrlRequestSchema,
ZUploadPdfRequestSchema,
} from './files.types';
import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf';
import getEnvelopeItemPdfByTokenRoute from './routes/get-envelope-item-pdf-by-token';
const envelopeItemTokenInclude = {
envelope: {
@@ -224,16 +225,14 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json({ error: 'Envelope item not found' }, 404);
}
const team = await getTeamById({
userId: userId,
const hasAccess = await checkEnvelopeFileAccess({
userId,
teamId: envelope.teamId,
}).catch((error) => {
console.error(error);
return null;
envelopeType: envelope.type,
templateType: envelope.templateType,
});
if (!team) {
if (!hasAccess) {
return c.json(
{ error: 'User does not have access to the team that this envelope is associated with' },
403,
@@ -292,16 +291,14 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json({ error: 'Envelope item not found' }, 404);
}
const team = await getTeamById({
const hasDownloadAccess = await checkEnvelopeFileAccess({
userId: session.user.id,
teamId: envelope.teamId,
}).catch((error) => {
console.error(error);
return null;
envelopeType: envelope.type,
templateType: envelope.templateType,
});
if (!team) {
if (!hasDownloadAccess) {
return c.json(
{ error: 'User does not have access to the team that this envelope is associated with' },
403,
@@ -376,3 +373,8 @@ export const filesRoute = new Hono<HonoEnv>()
});
},
);
// PDF routes for both tokens and auth based
// Is different to the other file endpoints since it uses documentDataId for hard caching.
filesRoute.route('/', getEnvelopeItemPdfRoute);
filesRoute.route('/', getEnvelopeItemPdfByTokenRoute);
@@ -0,0 +1,81 @@
import { sValidator } from '@hono/standard-validator';
import type { Prisma } from '@prisma/client';
import { Hono } from 'hono';
import { z } from 'zod';
import { prisma } from '@documenso/prisma';
import type { HonoEnv } from '../../../router';
import { handleEnvelopeItemPdfRequest } from './get-envelope-item-pdf';
const route = new Hono<HonoEnv>();
const ZGetEnvelopeItemByTokenParamsSchema = z.object({
token: z.string().min(1),
envelopeId: z.string().min(1),
envelopeItemId: z.string().min(1),
documentDataId: z.string().min(1),
version: z.enum(['initial', 'current']),
});
/**
* Returns a PDF file for an envelope item using a token.
*/
route.get(
'/token/:token/envelope/:envelopeId/envelopeItem/:envelopeItemId/dataId/:documentDataId/:version/item.pdf',
sValidator('param', ZGetEnvelopeItemByTokenParamsSchema),
async (c) => {
const { token, envelopeId, envelopeItemId, documentDataId, version } = c.req.valid('param');
if (!token) {
return c.json({ error: 'Not found' }, 404);
}
// Recipient token based query.
let envelopeItemWhereQuery: Prisma.EnvelopeItemWhereInput = {
id: envelopeItemId,
documentDataId,
envelope: {
id: envelopeId,
recipients: {
some: {
token,
},
},
},
};
// QR token based query.
if (token.startsWith('qr_')) {
envelopeItemWhereQuery = {
id: envelopeItemId,
documentDataId,
envelope: {
id: envelopeId,
qrToken: token,
},
};
}
// Validate envelope access.
const envelopeItem = await prisma.envelopeItem.findFirst({
where: envelopeItemWhereQuery,
include: {
documentData: true,
},
});
if (!envelopeItem) {
return c.json({ error: 'Not found' }, 404);
}
return await handleEnvelopeItemPdfRequest({
c,
envelopeItem,
version,
cacheStrategy: 'private',
});
},
);
export default route;
@@ -0,0 +1,157 @@
import { sValidator } from '@hono/standard-validator';
import type { DocumentData, EnvelopeItem } from '@prisma/client';
import { type Context, Hono } from 'hono';
import { z } from 'zod';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import type { DocumentDataVersion } from '@documenso/lib/types/document';
import { sha256 } from '@documenso/lib/universal/crypto';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { prisma } from '@documenso/prisma';
import type { HonoEnv } from '../../../router';
import { checkEnvelopeFileAccess } from '../files.helpers';
const route = new Hono<HonoEnv>();
const ZGetEnvelopeItemPdfRequestParamsSchema = z.object({
envelopeId: z.string().min(1),
envelopeItemId: z.string().min(1),
documentDataId: z.string().min(1),
version: z.enum(['initial', 'current']),
});
const ZGetEnvelopeItemPdfRequestQuerySchema = z.object({
presignToken: z.string().optional(),
});
/**
* Returns a PDF file for an envelope item.
*/
route.get(
'/envelope/:envelopeId/envelopeItem/:envelopeItemId/dataId/:documentDataId/:version/item.pdf',
sValidator('param', ZGetEnvelopeItemPdfRequestParamsSchema),
sValidator('query', ZGetEnvelopeItemPdfRequestQuerySchema),
async (c) => {
const { envelopeId, envelopeItemId, documentDataId, version } = c.req.valid('param');
const { presignToken } = c.req.valid('query');
const session = await getOptionalSession(c);
let userId = session.user?.id;
// Check presignToken if provided
if (presignToken) {
const verifiedToken = await verifyEmbeddingPresignToken({
token: presignToken,
}).catch(() => undefined);
userId = verifiedToken?.userId;
}
if (!userId) {
return c.json({ error: 'Not found' }, 404);
}
// Note: We authenticate whether the user can access this in the `getTeamById` below.
const envelopeItem = await prisma.envelopeItem.findFirst({
where: {
id: envelopeItemId,
envelopeId,
documentDataId,
},
include: {
documentData: true,
envelope: {
select: {
id: true,
type: true,
teamId: true,
templateType: true,
},
},
},
});
if (!envelopeItem) {
return c.json({ error: 'Not found' }, 404);
}
// Check whether the user has access to the document.
const hasAccess = await checkEnvelopeFileAccess({
userId,
teamId: envelopeItem.envelope.teamId,
envelopeType: envelopeItem.envelope.type,
templateType: envelopeItem.envelope.templateType,
});
if (!hasAccess) {
return c.json({ error: 'Not found' }, 404);
}
return await handleEnvelopeItemPdfRequest({
c,
envelopeItem,
version,
cacheStrategy: 'private',
});
},
);
type HandleEnvelopeItemPdfRequestOptions = {
c: Context<HonoEnv>;
envelopeItem: EnvelopeItem & {
documentData: DocumentData;
};
version: DocumentDataVersion;
/**
* The type of cache strategy to use.
*
* For access via tokens, we can use a public cache to allow the CDN to cache it.
*
* For access via session, we must use a private cache.
*/
cacheStrategy: 'private' | 'public';
};
export const handleEnvelopeItemPdfRequest = async ({
c,
envelopeItem,
version,
cacheStrategy,
}: HandleEnvelopeItemPdfRequestOptions) => {
// Determine which PDF data to use based on version requested.
const documentDataToUse =
version === 'current' ? envelopeItem.documentData.data : envelopeItem.documentData.initialData;
const etag = Buffer.from(sha256(documentDataToUse)).toString('hex');
if (c.req.header('If-None-Match') === etag) {
return c.status(304);
}
const file = await getFileServerSide({
type: envelopeItem.documentData.type,
data: documentDataToUse,
}).catch((error) => {
console.error(error);
return null;
});
if (!file) {
return c.json({ error: 'Not found' }, 404);
}
// Note: Only set these headers on success.
c.header('Content-Type', 'application/pdf');
c.header('ETag', etag);
c.header('Cache-Control', `${cacheStrategy}, max-age=31536000, immutable`);
return c.body(file);
};
export default route;