diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx index e20b94887..7082c04e1 100644 --- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx +++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx @@ -8,7 +8,7 @@ import { useRouter } from 'next/navigation'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { base64 } from '@documenso/lib/universal/base64'; -import { putFile } from '@documenso/lib/universal/upload/put-file'; +import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import type { Field, Recipient } from '@documenso/prisma/client'; import { DocumentDataType, Prisma } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; @@ -115,7 +115,7 @@ export const SinglePlayerClient = () => { } try { - const putFileData = await putFile(uploadedFile.file); + const putFileData = await putPdfFile(uploadedFile.file); const documentToken = await createSinglePlayerDocument({ documentData: { diff --git a/apps/web/src/app/(dashboard)/documents/upload-document.tsx b/apps/web/src/app/(dashboard)/documents/upload-document.tsx index 26f1e795c..0a0c029ab 100644 --- a/apps/web/src/app/(dashboard)/documents/upload-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/upload-document.tsx @@ -10,8 +10,9 @@ import { useSession } from 'next-auth/react'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; +import { AppError } from '@documenso/lib/errors/app-error'; import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; -import { putFile } from '@documenso/lib/universal/upload/put-file'; +import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; @@ -57,7 +58,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => { try { setIsLoading(true); - const { type, data } = await putFile(file); + const { type, data } = await putPdfFile(file); const { id: documentDataId } = await createDocumentData({ type, @@ -83,13 +84,21 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => { }); router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`); - } catch (error) { - console.error(error); + } catch (err) { + const error = AppError.parseError(err); - if (error instanceof TRPCClientError) { + console.error(err); + + if (error.code === 'INVALID_DOCUMENT_FILE') { + toast({ + title: 'Invalid file', + description: 'You cannot upload encrypted PDFs', + variant: 'destructive', + }); + } else if (err instanceof TRPCClientError) { toast({ title: 'Error', - description: error.message, + description: err.message, variant: 'destructive', }); } else { diff --git a/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx index ab05ac3dc..1a6e34584 100644 --- a/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx @@ -12,7 +12,7 @@ import * as z from 'zod'; import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; import { base64 } from '@documenso/lib/universal/base64'; -import { putFile } from '@documenso/lib/universal/upload/put-file'; +import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; @@ -98,7 +98,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo const file: File = uploadedFile.file; try { - const { type, data } = await putFile(file); + const { type, data } = await putPdfFile(file); const { id: templateDocumentDataId } = await createDocumentData({ type, diff --git a/apps/web/src/pages/api/auth/[...nextauth].ts b/apps/web/src/pages/api/auth/[...nextauth].ts index 365b6ec40..31f6e9ea3 100644 --- a/apps/web/src/pages/api/auth/[...nextauth].ts +++ b/apps/web/src/pages/api/auth/[...nextauth].ts @@ -2,6 +2,8 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import NextAuth from 'next-auth'; +import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { prisma } from '@documenso/prisma'; @@ -18,15 +20,29 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) { error: '/signin', }, events: { - signIn: async ({ user }) => { - await prisma.userSecurityAuditLog.create({ - data: { - userId: user.id, - ipAddress, - userAgent, - type: UserSecurityAuditLogType.SIGN_IN, - }, - }); + signIn: async ({ user: { id: userId } }) => { + const [user] = await Promise.all([ + await prisma.user.findFirstOrThrow({ + where: { + id: userId, + }, + }), + await prisma.userSecurityAuditLog.create({ + data: { + userId, + ipAddress, + userAgent, + type: UserSecurityAuditLogType.SIGN_IN, + }, + }), + ]); + + // Create the Stripe customer and attach it to the user if it doesn't exist. + if (user.customerId === null && IS_BILLING_ENABLED()) { + await getStripeCustomerByUser(user).catch((err) => { + console.error(err); + }); + } }, signOut: async ({ token }) => { const userId = typeof token.id === 'string' ? parseInt(token.id) : token.id; diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index 253803fc8..b1e069e35 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -22,7 +22,7 @@ import { updateRecipient } from '@documenso/lib/server-only/recipient/update-rec import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { getFile } from '@documenso/lib/universal/upload/get-file'; -import { putFile } from '@documenso/lib/universal/upload/put-file'; +import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { getPresignGetUrl, getPresignPostUrl, @@ -303,7 +303,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { formValues: body.formValues, }); - const newDocumentData = await putFile({ + const newDocumentData = await putPdfFile({ name: fileName, type: 'application/pdf', arrayBuffer: async () => Promise.resolve(prefilled), diff --git a/packages/ee/server-only/stripe/webhook/on-early-adopters-checkout.ts b/packages/ee/server-only/stripe/webhook/on-early-adopters-checkout.ts index 22f60069e..cda583e81 100644 --- a/packages/ee/server-only/stripe/webhook/on-early-adopters-checkout.ts +++ b/packages/ee/server-only/stripe/webhook/on-early-adopters-checkout.ts @@ -5,7 +5,7 @@ import { sealDocument } from '@documenso/lib/server-only/document/seal-document' import { redis } from '@documenso/lib/server-only/redis'; import { stripe } from '@documenso/lib/server-only/stripe'; import { alphaid, nanoid } from '@documenso/lib/universal/id'; -import { putFile } from '@documenso/lib/universal/upload/put-file'; +import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { prisma } from '@documenso/prisma'; import { DocumentStatus, @@ -74,7 +74,7 @@ export const onEarlyAdoptersCheckout = async ({ session }: OnEarlyAdoptersChecko new URL('@documenso/assets/documenso-supporter-pledge.pdf', import.meta.url), ).then(async (res) => res.arrayBuffer()); - const { id: documentDataId } = await putFile({ + const { id: documentDataId } = await putPdfFile({ name: 'Documenso Supporter Pledge.pdf', type: 'application/pdf', arrayBuffer: async () => Promise.resolve(documentBuffer), diff --git a/packages/lib/constants/feature-flags.ts b/packages/lib/constants/feature-flags.ts index 83137217f..9533f4d18 100644 --- a/packages/lib/constants/feature-flags.ts +++ b/packages/lib/constants/feature-flags.ts @@ -21,6 +21,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000; * Does not take any person or group properties into account. */ export const LOCAL_FEATURE_FLAGS: Record = { + app_allow_encrypted_documents: false, app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true', app_document_page_view_history_sheet: false, app_passkey: WEBAPP_BASE_URL === 'http://localhost:3000', // Temp feature flag. diff --git a/packages/lib/server-only/document/complete-document-with-token.ts b/packages/lib/server-only/document/complete-document-with-token.ts index d16b83ea1..29d17cc50 100644 --- a/packages/lib/server-only/document/complete-document-with-token.ts +++ b/packages/lib/server-only/document/complete-document-with-token.ts @@ -137,7 +137,7 @@ export const completeDocumentWithToken = async ({ await sendPendingEmail({ documentId, recipientId: recipient.id }); } - const documents = await prisma.document.updateMany({ + const haveAllRecipientsSigned = await prisma.document.findFirst({ where: { id: document.id, Recipient: { @@ -146,13 +146,9 @@ export const completeDocumentWithToken = async ({ }, }, }, - data: { - status: DocumentStatus.COMPLETED, - completedAt: new Date(), - }, }); - if (documents.count > 0) { + if (haveAllRecipientsSigned) { await sealDocument({ documentId: document.id, requestMetadata }); } diff --git a/packages/lib/server-only/document/delete-document.ts b/packages/lib/server-only/document/delete-document.ts index a097d76e9..bf4f8aa06 100644 --- a/packages/lib/server-only/document/delete-document.ts +++ b/packages/lib/server-only/document/delete-document.ts @@ -75,18 +75,20 @@ export const deleteDocument = async ({ } // Continue to hide the document from the user if they are a recipient. + // Dirty way of doing this but it's faster than refetching the document. if (userRecipient?.documentDeletedAt === null) { - await prisma.recipient.update({ - where: { - documentId_email: { - documentId: document.id, - email: user.email, + await prisma.recipient + .update({ + where: { + id: userRecipient.id, }, - }, - data: { - documentDeletedAt: new Date().toISOString(), - }, - }); + data: { + documentDeletedAt: new Date().toISOString(), + }, + }) + .catch(() => { + // Do nothing. + }); } // Return partial document for API v1 response. diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts index 3e366dc81..0546d96e3 100644 --- a/packages/lib/server-only/document/seal-document.ts +++ b/packages/lib/server-only/document/seal-document.ts @@ -14,7 +14,7 @@ import { signPdf } from '@documenso/signing'; import type { RequestMetadata } from '../../universal/extract-request-metadata'; import { getFile } from '../../universal/upload/get-file'; -import { putFile } from '../../universal/upload/put-file'; +import { putPdfFile } from '../../universal/upload/put-file'; import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf'; import { flattenAnnotations } from '../pdf/flatten-annotations'; import { insertFieldInPDF } from '../pdf/insert-field-in-pdf'; @@ -40,6 +40,11 @@ export const sealDocument = async ({ const document = await prisma.document.findFirstOrThrow({ where: { id: documentId, + Recipient: { + every: { + signingStatus: SigningStatus.SIGNED, + }, + }, }, include: { documentData: true, @@ -53,10 +58,6 @@ export const sealDocument = async ({ throw new Error(`Document ${document.id} has no document data`); } - if (document.status !== DocumentStatus.COMPLETED) { - throw new Error(`Document ${document.id} has not been completed`); - } - const recipients = await prisma.recipient.findMany({ where: { documentId: document.id, @@ -92,9 +93,9 @@ export const sealDocument = async ({ // !: Need to write the fields onto the document as a hard copy const pdfData = await getFile(documentData); - const certificate = await getCertificatePdf({ documentId }).then(async (doc) => - PDFDocument.load(doc), - ); + const certificate = await getCertificatePdf({ documentId }) + .then(async (doc) => PDFDocument.load(doc)) + .catch(() => null); const doc = await PDFDocument.load(pdfData); @@ -103,11 +104,13 @@ export const sealDocument = async ({ doc.getForm().flatten(); flattenAnnotations(doc); - const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices()); + if (certificate) { + const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices()); - certificatePages.forEach((page) => { - doc.addPage(page); - }); + certificatePages.forEach((page) => { + doc.addPage(page); + }); + } for (const field of fields) { await insertFieldInPDF(doc, field); @@ -119,7 +122,7 @@ export const sealDocument = async ({ const { name, ext } = path.parse(document.title); - const { data: newData } = await putFile({ + const { data: newData } = await putPdfFile({ name: `${name}_signed${ext}`, type: 'application/pdf', arrayBuffer: async () => Promise.resolve(pdfBuffer), @@ -138,6 +141,16 @@ export const sealDocument = async ({ } await prisma.$transaction(async (tx) => { + await tx.document.update({ + where: { + id: document.id, + }, + data: { + status: DocumentStatus.COMPLETED, + completedAt: new Date(), + }, + }); + await tx.documentData.update({ where: { id: documentData.id, diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index 9f68ed29b..40cdb9ab5 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -8,6 +8,7 @@ import { sealDocument } from '@documenso/lib/server-only/document/seal-document' import { updateDocument } from '@documenso/lib/server-only/document/update-document'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template'; import { prisma } from '@documenso/prisma'; @@ -20,7 +21,6 @@ import { RECIPIENT_ROLE_TO_EMAIL_TYPE, } from '../../constants/recipient-roles'; import { getFile } from '../../universal/upload/get-file'; -import { putFile } from '../../universal/upload/put-file'; import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; @@ -102,7 +102,7 @@ export const sendDocument = async ({ formValues: document.formValues as Record, }); - const newDocumentData = await putFile({ + const newDocumentData = await putPdfFile({ name: document.title, type: 'application/pdf', arrayBuffer: async () => Promise.resolve(prefilled), diff --git a/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts b/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts index dee40d41a..1b6150fb9 100644 --- a/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts +++ b/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts @@ -35,6 +35,7 @@ export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`, { waitUntil: 'networkidle', + timeout: 10_000, }); const result = await page.pdf({ diff --git a/packages/lib/universal/upload/put-file.ts b/packages/lib/universal/upload/put-file.ts index b6aab5f11..06ecd68ff 100644 --- a/packages/lib/universal/upload/put-file.ts +++ b/packages/lib/universal/upload/put-file.ts @@ -1,9 +1,12 @@ import { base64 } from '@scure/base'; import { env } from 'next-runtime-env'; +import { PDFDocument } from 'pdf-lib'; import { match } from 'ts-pattern'; +import { getFlag } from '@documenso/lib/universal/get-feature-flag'; import { DocumentDataType } from '@documenso/prisma/client'; +import { AppError } from '../../errors/app-error'; import { createDocumentData } from '../../server-only/document-data/create-document-data'; type File = { @@ -12,14 +15,38 @@ type File = { arrayBuffer: () => Promise; }; +/** + * Uploads a document file to the appropriate storage location and creates + * a document data record. + */ +export const putPdfFile = async (file: File) => { + const isEncryptedDocumentsAllowed = await getFlag('app_allow_encrypted_documents').catch( + () => false, + ); + + // This will prevent uploading encrypted PDFs or anything that can't be opened. + if (!isEncryptedDocumentsAllowed) { + await PDFDocument.load(await file.arrayBuffer()).catch((e) => { + console.error(`PDF upload parse error: ${e.message}`); + + throw new AppError('INVALID_DOCUMENT_FILE'); + }); + } + + const { type, data } = await putFile(file); + + return await createDocumentData({ type, data }); +}; + +/** + * Uploads a file to the appropriate storage location. + */ export const putFile = async (file: File) => { const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT'); - const { type, data } = await match(NEXT_PUBLIC_UPLOAD_TRANSPORT) + return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT) .with('s3', async () => putFileInS3(file)) .otherwise(async () => putFileInDatabase(file)); - - return await createDocumentData({ type, data }); }; const putFileInDatabase = async (file: File) => { diff --git a/packages/trpc/server/singleplayer-router/router.ts b/packages/trpc/server/singleplayer-router/router.ts index 33b125110..2634ca895 100644 --- a/packages/trpc/server/singleplayer-router/router.ts +++ b/packages/trpc/server/singleplayer-router/router.ts @@ -10,7 +10,7 @@ import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/cons import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf'; import { alphaid } from '@documenso/lib/universal/id'; import { getFile } from '@documenso/lib/universal/upload/get-file'; -import { putFile } from '@documenso/lib/universal/upload/put-file'; +import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { prisma } from '@documenso/prisma'; import { DocumentStatus, @@ -86,7 +86,7 @@ export const singleplayerRouter = router({ }, }); - const { id: documentDataId } = await putFile({ + const { id: documentDataId } = await putPdfFile({ name: `${documentName}.pdf`, type: 'application/pdf', arrayBuffer: async () => Promise.resolve(signedPdfBuffer), diff --git a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx index e415f1aac..d285fbe44 100644 --- a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx +++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx @@ -282,6 +282,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({