This commit is contained in:
David Nguyen
2025-02-12 16:41:35 +11:00
parent 548d92c2fc
commit 15922d447b
70 changed files with 889 additions and 551 deletions

View File

@ -1,14 +1,15 @@
import { createContext, useContext } from 'react';
import React from 'react';
import type { Session, User } from '@documenso/prisma/client';
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
import type { Session } from '@documenso/prisma/client';
import type { TGetTeamByUrlResponse } from '../../server-only/team/get-team';
import type { TGetTeamsResponse } from '../../server-only/team/get-teams';
export type AppSession = {
session: Session;
user: User; // Todo: Remove password, and redundant fields.
user: SessionUser;
currentTeam: TGetTeamByUrlResponse | null;
teams: TGetTeamsResponse;
};

View File

@ -114,9 +114,11 @@ export const run = async ({
emailMessage = customEmail?.message ?? '';
if (!emailMessage) {
const inviterName = user.name || '';
emailMessage = i18n._(
team.teamGlobalSettings?.includeSenderDetails
? msg`${user.name} on behalf of "${team.name}" has invited you to ${recipientActionVerb} the document "${document.title}".`
? msg`${inviterName} on behalf of "${team.name}" has invited you to ${recipientActionVerb} the document "${document.title}".`
: msg`${team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`,
);
}

View File

@ -19,8 +19,8 @@ import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../../types/webhook-payload';
import { getFile } from '../../../universal/upload/get-file';
import { putPdfFile } from '../../../universal/upload/put-file';
import { getFileServerSide } from '../../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../../universal/upload/put-file.server';
import { fieldsContainUnsignedRequiredField } from '../../../utils/advanced-fields-helpers';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import type { JobRunIO } from '../../client/_internal/job';
@ -114,7 +114,7 @@ export const run = async ({
documentData.data = documentData.initialData;
}
const pdfData = await getFile(documentData);
const pdfData = await getFileServerSide(documentData);
const certificateData =
(document.team?.teamGlobalSettings?.includeSigningCertificate ?? true)
@ -160,7 +160,7 @@ export const run = async ({
const { name } = path.parse(document.title);
const documentData = await putPdfFile({
const documentData = await putPdfFileServerSide({
name: `${name}_signed.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBuffer),

View File

@ -38,7 +38,7 @@
"@upstash/redis": "^1.20.6",
"@vvo/tzdb": "^6.117.0",
"inngest": "^3.19.13",
"kysely": "^0.26.3",
"kysely": "0.26.3",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"nanoid": "^4.0.2",

View File

@ -8,7 +8,10 @@ import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { validateTwoFactorAuthentication } from './validate-2fa';
type DisableTwoFactorAuthenticationOptions = {
user: Pick<User, 'id' | 'email' | 'twoFactorEnabled' | 'twoFactorSecret'>;
user: Pick<
User,
'id' | 'email' | 'twoFactorEnabled' | 'twoFactorSecret' | 'twoFactorBackupCodes'
>;
totpCode?: string;
backupCode?: string;
requestMetadata?: RequestMetadata;

View File

@ -24,8 +24,8 @@ import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { getFile } from '../../universal/upload/get-file';
import { putPdfFile } from '../../universal/upload/put-file';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -97,11 +97,11 @@ export const createDocumentV2 = async ({
});
if (documentData) {
const buffer = await getFile(documentData);
const buffer = await getFileServerSide(documentData);
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer));
const newDocumentData = await putPdfFile({
const newDocumentData = await putPdfFileServerSide({
name: title.endsWith('.pdf') ? title : `${title}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(normalizedPdf),

View File

@ -13,8 +13,8 @@ import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { getFile } from '../../universal/upload/get-file';
import { putPdfFile } from '../../universal/upload/put-file';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -96,11 +96,11 @@ export const createDocument = async ({
});
if (documentData) {
const buffer = await getFile(documentData);
const buffer = await getFileServerSide(documentData);
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer));
const newDocumentData = await putPdfFile({
const newDocumentData = await putPdfFileServerSide({
name: title.endsWith('.pdf') ? title : `${title}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(normalizedPdf),

View File

@ -14,8 +14,8 @@ import {
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file';
import { putPdfFile } from '../../universal/upload/put-file';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers';
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations';
@ -102,7 +102,7 @@ export const sealDocument = async ({
}
// !: Need to write the fields onto the document as a hard copy
const pdfData = await getFile(documentData);
const pdfData = await getFileServerSide(documentData);
const certificateData =
(document.team?.teamGlobalSettings?.includeSigningCertificate ?? true)
@ -142,7 +142,7 @@ export const sealDocument = async ({
const { name } = path.parse(document.title);
const { data: newData } = await putPdfFile({
const { data: newData } = await putPdfFileServerSide({
name: `${name}_signed.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBuffer),

View File

@ -12,7 +12,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { env } from '../../utils/env';
import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template';
@ -57,7 +57,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
const { user: owner } = document;
const completedDocument = await getFile(document.documentData);
const completedDocument = await getFileServerSide(document.documentData);
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';

View File

@ -9,7 +9,6 @@ import {
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } 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 { prisma } from '@documenso/prisma';
@ -19,7 +18,8 @@ import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { getFile } from '../../universal/upload/get-file';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -100,7 +100,7 @@ export const sendDocument = async ({
}
if (document.formValues) {
const file = await getFile(documentData);
const file = await getFileServerSide(documentData);
const prefilled = await insertFormValuesInPdf({
pdf: Buffer.from(file),
@ -114,7 +114,7 @@ export const sendDocument = async ({
fileName = `${document.title}.pdf`;
}
const newDocumentData = await putPdfFile({
const newDocumentData = await putPdfFileServerSide({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),

View File

@ -1,5 +1,7 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
export interface GetTemplateByDirectLinkTokenOptions {
token: string;
}
@ -7,7 +9,7 @@ export interface GetTemplateByDirectLinkTokenOptions {
export const getTemplateByDirectLinkToken = async ({
token,
}: GetTemplateByDirectLinkTokenOptions) => {
const template = await prisma.template.findFirstOrThrow({
const template = await prisma.template.findFirst({
where: {
directLink: {
token,
@ -26,8 +28,16 @@ export const getTemplateByDirectLinkToken = async ({
},
});
const directLink = template?.directLink;
// Doing this to enforce type safety for directLink.
if (!directLink) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
return {
...template,
directLink,
fields: template.recipients.map((recipient) => recipient.fields).flat(),
};
};

View File

@ -0,0 +1,50 @@
import { DocumentDataType } from '@prisma/client';
import { base64 } from '@scure/base';
import { match } from 'ts-pattern';
export type GetFileOptions = {
type: DocumentDataType;
data: string;
};
export const getFileServerSide = async ({ type, data }: GetFileOptions) => {
return await match(type)
.with(DocumentDataType.BYTES, () => getFileFromBytes(data))
.with(DocumentDataType.BYTES_64, () => getFileFromBytes64(data))
.with(DocumentDataType.S3_PATH, async () => getFileFromS3(data))
.exhaustive();
};
const getFileFromBytes = (data: string) => {
const encoder = new TextEncoder();
const binaryData = encoder.encode(data);
return binaryData;
};
const getFileFromBytes64 = (data: string) => {
const binaryData = base64.decode(data);
return binaryData;
};
const getFileFromS3 = async (key: string) => {
const { getPresignGetUrl } = await import('./server-actions');
const { url } = await getPresignGetUrl(key);
const response = await fetch(url, {
method: 'GET',
});
if (!response.ok) {
throw new Error(`Failed to get file "${key}", failed with status code ${response.status}`);
}
const buffer = await response.arrayBuffer();
const binaryData = new Uint8Array(buffer);
return binaryData;
};

View File

@ -30,9 +30,23 @@ const getFileFromBytes64 = (data: string) => {
};
const getFileFromS3 = async (key: string) => {
const { getPresignGetUrl } = await import('./server-actions');
const getPresignedUrlResponse = await fetch(`/api/files/presigned-get-url`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
key,
}),
});
const { url } = await getPresignGetUrl(key);
if (!getPresignedUrlResponse.ok) {
throw new Error(
`Failed to get presigned url with key "${key}", failed with status code ${getPresignedUrlResponse.status}`,
);
}
const { url } = await getPresignedUrlResponse.json();
const response = await fetch(url, {
method: 'GET',

View File

@ -0,0 +1,85 @@
import { DocumentDataType } from '@prisma/client';
import { base64 } from '@scure/base';
import { PDFDocument } from 'pdf-lib';
import { match } from 'ts-pattern';
import { env } from '@documenso/lib/utils/env';
import { AppError } from '../../errors/app-error';
import { createDocumentData } from '../../server-only/document-data/create-document-data';
import { uploadS3File } from './server-actions';
type File = {
name: string;
type: string;
arrayBuffer: () => Promise<ArrayBuffer>;
};
/**
* Uploads a document file to the appropriate storage location and creates
* a document data record.
*/
export const putPdfFileServerSide = async (file: File) => {
const isEncryptedDocumentsAllowed = false; // Was feature flag.
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 (!isEncryptedDocumentsAllowed && pdf.isEncrypted) {
throw new AppError('INVALID_DOCUMENT_FILE');
}
if (!file.name.endsWith('.pdf')) {
file.name = `${file.name}.pdf`;
}
const { type, data } = await putFileServerSide(file);
return await createDocumentData({ type, data });
};
/**
* Uploads a file to the appropriate storage location.
*/
export const putFileServerSide = async (file: File) => {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
.with('s3', async () => putFileInS3(file))
.otherwise(async () => putFileInDatabase(file));
};
const putFileInDatabase = async (file: File) => {
const contents = await file.arrayBuffer();
const binaryData = new Uint8Array(contents);
const asciiData = base64.encode(binaryData);
return {
type: DocumentDataType.BYTES_64,
data: asciiData,
};
};
const putFileInS3 = async (file: File) => {
const buffer = await file.arrayBuffer();
const blob = new Blob([buffer], { type: file.type });
const newFile = new File([blob], file.name, {
type: file.type,
});
const { key } = await uploadS3File(newFile);
return {
type: DocumentDataType.S3_PATH,
data: key,
};
};

View File

@ -1,12 +1,15 @@
import { DocumentDataType } from '@prisma/client';
import { base64 } from '@scure/base';
import { PDFDocument } from 'pdf-lib';
import { match } from 'ts-pattern';
import { env } from '@documenso/lib/utils/env';
import type {
TGetPresignedPostUrlResponse,
TUploadPdfResponse,
} from '@documenso/remix/server/api/files.types';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError } from '../../errors/app-error';
import { createDocumentData } from '../../server-only/document-data/create-document-data';
type File = {
name: string;
@ -14,32 +17,29 @@ type File = {
arrayBuffer: () => Promise<ArrayBuffer>;
};
/**
* Uploads a document file to the appropriate storage location and creates
* a document data record.
*/
export const putPdfFile = async (file: File) => {
const isEncryptedDocumentsAllowed = false; // Was feature flag.
const formData = new FormData();
const arrayBuffer = await file.arrayBuffer();
// Create a proper File object from the data
const buffer = await file.arrayBuffer();
const blob = new Blob([buffer], { type: file.type });
const properFile = new File([blob], file.name, { type: file.type });
const pdf = await PDFDocument.load(arrayBuffer).catch((e) => {
console.error(`PDF upload parse error: ${e.message}`);
formData.append('file', properFile);
throw new AppError('INVALID_DOCUMENT_FILE');
const response = await fetch('/api/files/upload-pdf', {
method: 'POST',
body: formData,
});
if (!isEncryptedDocumentsAllowed && pdf.isEncrypted) {
throw new AppError('INVALID_DOCUMENT_FILE');
if (!response.ok) {
console.error('Upload failed:', response.statusText);
throw new AppError('UPLOAD_FAILED');
}
if (!file.name.endsWith('.pdf')) {
file.name = `${file.name}.pdf`;
}
const result: TUploadPdfResponse = await response.json();
const { type, data } = await putFile(file);
return await createDocumentData({ type, data });
return result;
};
/**
@ -67,9 +67,27 @@ const putFileInDatabase = async (file: File) => {
};
const putFileInS3 = async (file: File) => {
const { getPresignPostUrl } = await import('./server-actions');
const getPresignedUrlResponse = await fetch(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/files/presigned-post-url`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
fileName: file.name,
contentType: file.type,
}),
},
);
const { url, key } = await getPresignPostUrl(file.name, file.type);
if (!getPresignedUrlResponse.ok) {
throw new Error(
`Failed to get presigned post url, failed with status code ${getPresignedUrlResponse.status}`,
);
}
const { url, key }: TGetPresignedPostUrlResponse = await getPresignedUrlResponse.json();
const body = await file.arrayBuffer();

View File

@ -9,37 +9,21 @@ import path from 'node:path';
import { env } from '@documenso/lib/utils/env';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { ONE_HOUR, ONE_SECOND } from '../../constants/time';
import { alphaid } from '../id';
export const getPresignPostUrl = async (fileName: string, contentType: string) => {
export const getPresignPostUrl = async (fileName: string, contentType: string, userId?: number) => {
const client = getS3Client();
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
const token: { id: string } | null = null;
try {
const baseUrl = NEXT_PUBLIC_WEBAPP_URL();
// Todo
// token = await getToken({
// req: new NextRequest(baseUrl, {
// headers: headers(),
// }),
// });
} catch (err) {
// Non server-component environment
}
// Get the basename and extension for the file
const { name, ext } = path.parse(fileName);
let key = `${alphaid(12)}/${slugify(name)}${ext}`;
if (token) {
key = `${token.id}/${key}`;
if (userId) {
key = `${userId}/${key}`;
}
const putObjectCommand = new PutObjectCommand({
@ -104,6 +88,31 @@ export const getPresignGetUrl = async (key: string) => {
return { key, url };
};
/**
* Uploads a file to S3.
*/
export const uploadS3File = async (file: File) => {
const client = getS3Client();
// Get the basename and extension for the file
const { name, ext } = path.parse(file.name);
const key = `${alphaid(12)}/${slugify(name)}${ext}`;
const fileBuffer = await file.arrayBuffer();
const response = await client.send(
new PutObjectCommand({
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
Body: Buffer.from(fileBuffer),
ContentType: file.type,
}),
);
return { key, response };
};
export const deleteS3File = async (key: string) => {
const client = getS3Client();