From 717fa8f870f78ffe7045d7167c4b7d984169e892 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 4 Nov 2025 15:18:11 +1100 Subject: [PATCH] fix: add endpoints for getting files --- .../dialogs/envelope-download-dialog.tsx | 16 +- .../forms/branding-preferences-form.tsx | 24 +- apps/remix/server/api/files.helpers.ts | 82 ++++++ apps/remix/server/api/files.ts | 276 +++++++++++++++--- apps/remix/server/api/files.types.ts | 48 ++- packages/lib/universal/crypto.ts | 2 + packages/lib/universal/upload/get-file.ts | 8 +- 7 files changed, 378 insertions(+), 78 deletions(-) create mode 100644 apps/remix/server/api/files.helpers.ts diff --git a/apps/remix/app/components/dialogs/envelope-download-dialog.tsx b/apps/remix/app/components/dialogs/envelope-download-dialog.tsx index c735c773c..c2fd29b8f 100644 --- a/apps/remix/app/components/dialogs/envelope-download-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-download-dialog.tsx @@ -6,7 +6,7 @@ import { type DocumentData, DocumentStatus, type EnvelopeItem } from '@prisma/cl import { DownloadIcon, FileTextIcon } from 'lucide-react'; import { downloadFile } from '@documenso/lib/client-only/download-file'; -import { getFile } from '@documenso/lib/universal/upload/get-file'; +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -87,17 +87,11 @@ export const EnvelopeDownloadDialog = ({ })); try { - const data = await getFile({ - type: envelopeItem.documentData.type, - data: - version === 'signed' - ? envelopeItem.documentData.data - : envelopeItem.documentData.initialData, - }); + const downloadUrl = token + ? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${envelopeItemId}/download/${version}` + : `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${envelopeItemId}/download/${version}`; - const blob = new Blob([data], { - type: 'application/pdf', - }); + const blob = await fetch(downloadUrl).then(async (res) => await res.blob()); const baseTitle = envelopeItem.title.replace(/\.pdf$/, ''); const suffix = version === 'signed' ? '_signed.pdf' : '.pdf'; diff --git a/apps/remix/app/components/forms/branding-preferences-form.tsx b/apps/remix/app/components/forms/branding-preferences-form.tsx index 85355a7b1..9c8bc05ce 100644 --- a/apps/remix/app/components/forms/branding-preferences-form.tsx +++ b/apps/remix/app/components/forms/branding-preferences-form.tsx @@ -1,14 +1,14 @@ import { useEffect, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useLingui } from '@lingui/react/macro'; -import { Trans } from '@lingui/react/macro'; +import { Trans, useLingui } from '@lingui/react/macro'; import type { TeamGlobalSettings } from '@prisma/client'; import { Loader } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; -import { getFile } from '@documenso/lib/universal/upload/get-file'; +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -29,6 +29,8 @@ import { } from '@documenso/ui/primitives/select'; import { Textarea } from '@documenso/ui/primitives/textarea'; +import { useOptionalCurrentTeam } from '~/providers/team'; + const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp']; @@ -68,6 +70,9 @@ export function BrandingPreferencesForm({ }: BrandingPreferencesFormProps) { const { t } = useLingui(); + const team = useOptionalCurrentTeam(); + const organisation = useCurrentOrganisation(); + const [previewUrl, setPreviewUrl] = useState(''); const [hasLoadedPreview, setHasLoadedPreview] = useState(false); @@ -88,14 +93,13 @@ export function BrandingPreferencesForm({ const file = JSON.parse(settings.brandingLogo); if ('type' in file && 'data' in file) { - void getFile(file).then((binaryData) => { - const objectUrl = URL.createObjectURL(new Blob([binaryData])); + const logoUrl = + context === 'Team' + ? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}` + : `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`; - setPreviewUrl(objectUrl); - setHasLoadedPreview(true); - }); - - return; + setPreviewUrl(logoUrl); + setHasLoadedPreview(true); } } diff --git a/apps/remix/server/api/files.helpers.ts b/apps/remix/server/api/files.helpers.ts new file mode 100644 index 000000000..7b10e8a8a --- /dev/null +++ b/apps/remix/server/api/files.helpers.ts @@ -0,0 +1,82 @@ +import { type DocumentDataType, DocumentStatus } from '@prisma/client'; +import { type Context } from 'hono'; + +import { sha256 } from '@documenso/lib/universal/crypto'; +import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server'; + +import type { HonoEnv } from '../router'; + +type HandleEnvelopeItemFileRequestOptions = { + title: string; + status: DocumentStatus; + documentData: { + type: DocumentDataType; + data: string; + initialData: string; + }; + version: 'signed' | 'original'; + isDownload: boolean; + context: Context; +}; + +/** + * Helper function to handle envelope item file requests (both view and download) + */ +export const handleEnvelopeItemFileRequest = async ({ + title, + status, + documentData, + version, + isDownload, + context: c, +}: HandleEnvelopeItemFileRequestOptions) => { + const documentDataToUse = version === 'signed' ? documentData.data : documentData.initialData; + + const etag = Buffer.from(sha256(documentDataToUse)).toString('hex'); + + if (c.req.header('If-None-Match') === etag) { + 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('Content-Length', file.length.toString()); + c.header('ETag', etag); + + if (!isDownload) { + if (status === DocumentStatus.COMPLETED) { + c.header('Cache-Control', 'public, max-age=31536000, immutable'); + } else { + // Set a tiny 1 minute cache, with must-revalidate to ensure the client always checks for updates. + c.header('Cache-Control', 'public, max-age=60, 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', `attachment; filename="${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); +}; diff --git a/apps/remix/server/api/files.ts b/apps/remix/server/api/files.ts index 440189c5a..d1993bf05 100644 --- a/apps/remix/server/api/files.ts +++ b/apps/remix/server/api/files.ts @@ -1,21 +1,22 @@ -import { PDFDocument } from '@cantoo/pdf-lib'; import { sValidator } from '@hono/standard-validator'; import { Hono } 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 { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; -import { putFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; -import { - getPresignGetUrl, - getPresignPostUrl, -} from '@documenso/lib/universal/upload/server-actions'; +import { getTeamById } from '@documenso/lib/server-only/team/get-team'; +import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; +import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; +import { prisma } from '@documenso/prisma'; import type { HonoEnv } from '../router'; +import { handleEnvelopeItemFileRequest } from './files.helpers'; import { - type TGetPresignedGetUrlResponse, type TGetPresignedPostUrlResponse, - ZGetPresignedGetUrlRequestSchema, + ZGetEnvelopeItemFileDownloadRequestParamsSchema, + ZGetEnvelopeItemFileRequestParamsSchema, + ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema, + ZGetEnvelopeItemFileTokenRequestParamsSchema, ZGetPresignedPostUrlRequestSchema, ZUploadPdfRequestSchema, } from './files.types'; @@ -42,29 +43,7 @@ export const filesRoute = new Hono() return c.json({ error: 'File too large' }, 400); } - const arrayBuffer = await file.arrayBuffer(); - - const pdf = await PDFDocument.load(arrayBuffer).catch((e) => { - console.error(`PDF upload parse error: ${e.message}`); - - throw new AppError('INVALID_DOCUMENT_FILE'); - }); - - if (pdf.isEncrypted) { - throw new AppError('INVALID_DOCUMENT_FILE'); - } - - // Todo: (RR7) Test this. - if (!file.name.endsWith('.pdf')) { - Object.defineProperty(file, 'name', { - writable: true, - value: `${file.name}.pdf`, - }); - } - - const { type, data } = await putFileServerSide(file); - - const result = await createDocumentData({ type, data }); + const result = await putNormalizedPdfFileServerSide(file); return c.json(result); } catch (error) { @@ -72,19 +51,6 @@ export const filesRoute = new Hono() return c.json({ error: 'Upload failed' }, 500); } }) - .post('/presigned-get-url', sValidator('json', ZGetPresignedGetUrlRequestSchema), async (c) => { - const { key } = await c.req.json(); - - try { - const { url } = await getPresignGetUrl(key || ''); - - return c.json({ url } satisfies TGetPresignedGetUrlResponse); - } catch (err) { - console.error(err); - - throw new AppError(AppErrorCode.UNKNOWN_ERROR); - } - }) .post('/presigned-post-url', sValidator('json', ZGetPresignedPostUrlRequestSchema), async (c) => { const { fileName, contentType } = c.req.valid('json'); @@ -97,4 +63,222 @@ export const filesRoute = new Hono() throw new AppError(AppErrorCode.UNKNOWN_ERROR); } - }); + }) + .get( + '/envelope/:envelopeId/envelopeItem/:envelopeItemId', + sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema), + async (c) => { + const { envelopeId, envelopeItemId } = c.req.valid('param'); + + const session = await getOptionalSession(c); + + if (!session.user) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const envelope = await prisma.envelope.findFirst({ + where: { + id: envelopeId, + }, + include: { + envelopeItems: { + where: { + id: envelopeItemId, + }, + include: { + documentData: true, + }, + }, + }, + }); + + if (!envelope) { + return c.json({ error: 'Envelope not found' }, 404); + } + + const [envelopeItem] = envelope.envelopeItems; + + if (!envelopeItem) { + return c.json({ error: 'Envelope item not found' }, 404); + } + + const team = await getTeamById({ + userId: session.user.id, + teamId: envelope.teamId, + }).catch((error) => { + console.error(error); + + return null; + }); + + if (!team) { + return c.json( + { error: 'User does not have access to the team that this envelope is associated with' }, + 403, + ); + } + + if (!envelopeItem.documentData) { + return c.json({ error: 'Document data not found' }, 404); + } + + return await handleEnvelopeItemFileRequest({ + title: envelopeItem.title, + status: envelope.status, + documentData: envelopeItem.documentData, + version: 'signed', + isDownload: false, + context: c, + }); + }, + ) + .get( + '/envelope/:envelopeId/envelopeItem/:envelopeItemId/download/:version?', + sValidator('param', ZGetEnvelopeItemFileDownloadRequestParamsSchema), + async (c) => { + const { envelopeId, envelopeItemId, version } = c.req.valid('param'); + + const session = await getOptionalSession(c); + + if (!session.user) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const envelope = await prisma.envelope.findFirst({ + where: { + id: envelopeId, + }, + include: { + envelopeItems: { + where: { + id: envelopeItemId, + }, + include: { + documentData: true, + }, + }, + }, + }); + + if (!envelope) { + return c.json({ error: 'Envelope not found' }, 404); + } + + const [envelopeItem] = envelope.envelopeItems; + + if (!envelopeItem) { + return c.json({ error: 'Envelope item not found' }, 404); + } + + const team = await getTeamById({ + userId: session.user.id, + teamId: envelope.teamId, + }).catch((error) => { + console.error(error); + + return null; + }); + + if (!team) { + return c.json( + { error: 'User does not have access to the team that this envelope is associated with' }, + 403, + ); + } + + if (!envelopeItem.documentData) { + return c.json({ error: 'Document data not found' }, 404); + } + + return await handleEnvelopeItemFileRequest({ + title: envelopeItem.title, + status: envelope.status, + documentData: envelopeItem.documentData, + version, + isDownload: true, + context: c, + }); + }, + ) + .get( + '/token/:token/envelopeItem/:envelopeItemId', + sValidator('param', ZGetEnvelopeItemFileTokenRequestParamsSchema), + async (c) => { + const { token, envelopeItemId } = c.req.valid('param'); + + const envelopeItem = await prisma.envelopeItem.findFirst({ + where: { + id: envelopeItemId, + envelope: { + recipients: { + some: { + token, + }, + }, + }, + }, + include: { + envelope: true, + documentData: true, + }, + }); + + if (!envelopeItem) { + return c.json({ error: 'Envelope item not found' }, 404); + } + + if (!envelopeItem.documentData) { + return c.json({ error: 'Document data not found' }, 404); + } + + return await handleEnvelopeItemFileRequest({ + title: envelopeItem.title, + status: envelopeItem.envelope.status, + documentData: envelopeItem.documentData, + version: 'signed', + isDownload: false, + context: c, + }); + }, + ) + .get( + '/token/:token/envelopeItem/:envelopeItemId/download/:version?', + sValidator('param', ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema), + async (c) => { + const { token, envelopeItemId, version } = c.req.valid('param'); + + const envelopeItem = await prisma.envelopeItem.findFirst({ + where: { + id: envelopeItemId, + envelope: { + recipients: { + some: { + token, + }, + }, + }, + }, + include: { + envelope: true, + documentData: true, + }, + }); + + if (!envelopeItem) { + return c.json({ error: 'Envelope item not found' }, 404); + } + + if (!envelopeItem.documentData) { + return c.json({ error: 'Document data not found' }, 404); + } + + return await handleEnvelopeItemFileRequest({ + title: envelopeItem.title, + status: envelopeItem.envelope.status, + documentData: envelopeItem.documentData, + version, + isDownload: true, + context: c, + }); + }, + ); diff --git a/apps/remix/server/api/files.types.ts b/apps/remix/server/api/files.types.ts index 8c120cccd..444dac8ae 100644 --- a/apps/remix/server/api/files.types.ts +++ b/apps/remix/server/api/files.types.ts @@ -24,15 +24,43 @@ export const ZGetPresignedPostUrlResponseSchema = z.object({ url: z.string().min(1), }); -export const ZGetPresignedGetUrlRequestSchema = z.object({ - key: z.string().min(1), -}); - -export const ZGetPresignedGetUrlResponseSchema = z.object({ - url: z.string().min(1), -}); - export type TGetPresignedPostUrlRequest = z.infer; export type TGetPresignedPostUrlResponse = z.infer; -export type TGetPresignedGetUrlRequest = z.infer; -export type TGetPresignedGetUrlResponse = z.infer; + +export const ZGetEnvelopeItemFileRequestParamsSchema = z.object({ + envelopeId: z.string().min(1), + envelopeItemId: z.string().min(1), +}); + +export type TGetEnvelopeItemFileRequestParams = z.infer< + typeof ZGetEnvelopeItemFileRequestParamsSchema +>; + +export const ZGetEnvelopeItemFileTokenRequestParamsSchema = z.object({ + token: z.string().min(1), + envelopeItemId: z.string().min(1), +}); + +export type TGetEnvelopeItemFileTokenRequestParams = z.infer< + typeof ZGetEnvelopeItemFileTokenRequestParamsSchema +>; + +export const ZGetEnvelopeItemFileDownloadRequestParamsSchema = z.object({ + envelopeId: z.string().min(1), + envelopeItemId: z.string().min(1), + version: z.enum(['signed', 'original']).default('signed'), +}); + +export type TGetEnvelopeItemFileDownloadRequestParams = z.infer< + typeof ZGetEnvelopeItemFileDownloadRequestParamsSchema +>; + +export const ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema = z.object({ + token: z.string().min(1), + envelopeItemId: z.string().min(1), + version: z.enum(['signed', 'original']).default('signed'), +}); + +export type TGetEnvelopeItemFileTokenDownloadRequestParams = z.infer< + typeof ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema +>; diff --git a/packages/lib/universal/crypto.ts b/packages/lib/universal/crypto.ts index 405208d7f..22d64a1f2 100644 --- a/packages/lib/universal/crypto.ts +++ b/packages/lib/universal/crypto.ts @@ -30,3 +30,5 @@ export const symmetricDecrypt = ({ key, data }: SymmetricDecryptOptions) => { return chacha.decrypt(dataAsBytes); }; + +export { sha256 }; diff --git a/packages/lib/universal/upload/get-file.ts b/packages/lib/universal/upload/get-file.ts index 6ac631c13..ac62b1744 100644 --- a/packages/lib/universal/upload/get-file.ts +++ b/packages/lib/universal/upload/get-file.ts @@ -7,7 +7,13 @@ export type GetFileOptions = { data: string; }; -export const getFile = async ({ type, data }: GetFileOptions) => { +/** + * KEPT FOR POSTERITY, SHOULD BE REMOVED IN THE FUTURE + * DO NOT USE OR I WILL FIRE YOU + * + * - Lucas, 2025-11-04 + */ +const getFile = async ({ type, data }: GetFileOptions) => { return await match(type) .with(DocumentDataType.BYTES, () => getFileFromBytes(data)) .with(DocumentDataType.BYTES_64, () => getFileFromBytes64(data))