feat: universal upload

Implementation of a universal upload allowing for multiple storage backends
starting with `database` and `s3`.

Allows clients to put and retrieve files from either client or server using
a blend of client and server actions.
This commit is contained in:
Mythie
2023-09-14 12:46:36 +10:00
parent 72bec7bc34
commit 3afc35c40c
42 changed files with 2372 additions and 308 deletions

View File

@ -15,6 +15,20 @@ NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documen
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
# [[STORAGE]]
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
NEXT_PRIVATE_UPLOAD_ENDPOINT=
# OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1.
NEXT_PRIVATE_UPLOAD_REGION=
# REQUIRED: Defines the bucket to use for the S3 storage transport.
NEXT_PRIVATE_UPLOAD_BUCKET=
# OPTIONAL: Defines the access key ID to use for the S3 storage transport.
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID=
# OPTIONAL: Defines the secret access key to use for the S3 storage transport.
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY=
# [[SMTP]]
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth"

View File

@ -8,9 +8,16 @@ const { parsed: env } = require('dotenv').config({
/** @type {import('next').NextConfig} */
const config = {
experimental: {
serverActions: true,
},
reactStrictMode: true,
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
env,
modularizeImports: {
'lucide-react': {
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
},
},
};
module.exports = withContentlayer(config);

View File

@ -8,6 +8,8 @@ import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
import { redis } from '@documenso/lib/server-only/redis';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { updateFile } from '@documenso/lib/universal/upload/update-file';
import { prisma } from '@documenso/prisma';
import {
DocumentDataType,
@ -88,19 +90,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const bytes64 = readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64');
const { id: documentDataId } = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: bytes64,
initialData: bytes64,
},
});
const document = await prisma.document.create({
data: {
title: 'Documenso Supporter Pledge.pdf',
status: DocumentStatus.COMPLETED,
userId: user.id,
created: now,
documentData: {
create: {
type: DocumentDataType.BYTES_64,
data: bytes64,
initialData: bytes64,
},
},
documentDataId,
},
include: {
documentData: true,
@ -139,17 +143,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
});
let pdfData = await getFile(documentData).then((data) =>
Buffer.from(data).toString('base64'),
);
if (signatureDataUrl) {
documentData.data = await insertImageInPDF(
documentData.data,
pdfData = await insertImageInPDF(
pdfData,
signatureDataUrl,
Number(field.positionX),
Number(field.positionY),
field.page,
);
} else {
documentData.data = await insertTextInPDF(
documentData.data,
pdfData = await insertTextInPDF(
pdfData,
signatureText ?? '',
Number(field.positionX),
Number(field.positionY),
@ -157,6 +165,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
);
}
const { data: newData } = await updateFile({
type: documentData.type,
oldData: documentData.initialData,
newData: Buffer.from(pdfData, 'base64').toString('binary'),
});
await Promise.all([
prisma.signature.create({
data: {
@ -166,16 +180,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
typedSignature: signatureDataUrl ? '' : signatureText,
},
}),
prisma.document.update({
prisma.documentData.update({
where: {
id: document.id,
id: documentData.id,
},
data: {
documentData: {
update: {
data: documentData.data,
},
},
data: newData,
},
}),
]);

View File

@ -10,6 +10,7 @@ const { parsed: env } = require('dotenv').config({
const config = {
experimental: {
serverActions: true,
serverActionsBodySizeLimit: '50mb',
},
reactStrictMode: true,
transpilePackages: [
@ -19,10 +20,6 @@ const config = {
'@documenso/ui',
'@documenso/email',
],
env: {
...env,
APP_VERSION: version,
},
modularizeImports: {
'lucide-react': {
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',

View File

@ -24,7 +24,6 @@
"lucide-react": "^0.214.0",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"nanoid": "^4.0.2",
"next": "13.4.12",
"next-auth": "4.22.3",
"next-plausible": "^3.10.1",

View File

@ -1,34 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import { TCreateDocumentRequestSchema, ZCreateDocumentResponseSchema } from './types';
export const useCreateDocument = () => {
return useMutation(async ({ file }: TCreateDocumentRequestSchema) => {
const formData = new FormData();
formData.set('file', file);
const response = await fetch('/api/document/create', {
method: 'POST',
body: formData,
});
const body = await response.json();
if (response.status !== 200) {
throw new Error('Failed to create document');
}
const safeBody = ZCreateDocumentResponseSchema.safeParse(body);
if (!safeBody.success) {
throw new Error('Failed to create document');
}
if ('error' in safeBody.data) {
throw new Error(safeBody.data.error);
}
return safeBody.data;
});
};

View File

@ -1,19 +0,0 @@
import { z } from 'zod';
export const ZCreateDocumentRequestSchema = z.object({
file: z.instanceof(File),
});
export type TCreateDocumentRequestSchema = z.infer<typeof ZCreateDocumentRequestSchema>;
export const ZCreateDocumentResponseSchema = z
.object({
id: z.number(),
})
.or(
z.object({
error: z.string(),
}),
);
export type TCreateDocumentResponseSchema = z.infer<typeof ZCreateDocumentResponseSchema>;

View File

@ -32,6 +32,7 @@ export type EditDocumentFormProps = {
document: DocumentWithData;
recipients: Recipient[];
fields: Field[];
dataUrl: string;
};
type EditDocumentStep = 'signers' | 'fields' | 'subject';
@ -42,16 +43,13 @@ export const EditDocumentForm = ({
recipients,
fields,
user: _user,
dataUrl,
}: EditDocumentFormProps) => {
const { toast } = useToast();
const router = useRouter();
const { documentData } = document;
const [step, setStep] = useState<EditDocumentStep>('signers');
const documentUrl = `data:application/pdf;base64,${documentData?.data}`;
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
signers: {
title: 'Add Signers',
@ -154,11 +152,11 @@ export const EditDocumentForm = ({
return (
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
<Card
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
gradient
>
<CardContent className="p-2">
<LazyPDFViewer document={documentUrl} />
<LazyPDFViewer document={dataUrl} />
</CardContent>
</Card>

View File

@ -1,20 +0,0 @@
'use client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { PDFViewerProps } from '@documenso/ui/primitives/pdf-viewer';
export type LoadablePDFCard = PDFViewerProps & {
className?: string;
pdfClassName?: string;
};
export const LoadablePDFCard = ({ className, pdfClassName, ...props }: LoadablePDFCard) => {
return (
<Card className={className} gradient {...props}>
<CardContent className="p-2">
<LazyPDFViewer className={pdfClassName} {...props} />
</CardContent>
</Card>
);
};

View File

@ -7,6 +7,7 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
@ -42,6 +43,12 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
const { documentData } = document;
const documentDataUrl = await getFile(documentData)
.then((buffer) => Buffer.from(buffer).toString('base64'))
.then((data) => `data:application/pdf;base64,${data}`);
console.log({ documentDataUrl: documentDataUrl.slice(0, 40) });
const [recipients, fields] = await Promise.all([
await getRecipientsForDocument({
documentId,
@ -88,12 +95,13 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
user={session}
recipients={recipients}
fields={fields}
dataUrl={documentDataUrl}
/>
)}
{document.status === InternalDocumentStatus.COMPLETED && (
<div className="mx-auto mt-12 max-w-2xl">
<LazyPDFViewer document={`data:application/pdf;base64,${documentData.data}`} />
<LazyPDFViewer document={documentDataUrl} />
</div>
)}
</div>

View File

@ -1,29 +1,45 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCreateDocument } from '~/api/document/create/fetcher';
export type UploadDocumentProps = {
className?: string;
};
export const UploadDocument = ({ className }: UploadDocumentProps) => {
const { toast } = useToast();
const router = useRouter();
const { isLoading, mutateAsync: createDocument } = useCreateDocument();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
const onFileDrop = async (file: File) => {
try {
setIsLoading(true);
const { type, data } = await putFile(file);
const { id: documentDataId } = await createDocumentData({
type,
data,
});
const { id } = await createDocument({
file: file,
title: file.name,
documentDataId,
});
toast({
@ -41,6 +57,8 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
description: 'An error occurred while uploading your document.',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};

View File

@ -1,55 +1,55 @@
'use client';
import { HTMLAttributes } from 'react';
import { HTMLAttributes, useState } from 'react';
import { Download } from 'lucide-react';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { DocumentData } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
disabled?: boolean;
fileName?: string;
document?: string;
documentData?: DocumentData;
};
export const DownloadButton = ({
className,
fileName,
document,
documentData,
disabled,
...props
}: DownloadButtonProps) => {
/**
* Convert the document from base64 to a blob and download it.
*/
const onDownloadClick = () => {
if (!document) {
return;
}
let decodedDocument = document;
const [isLoading, setIsLoading] = useState(false);
const onDownloadClick = async () => {
try {
decodedDocument = atob(document);
setIsLoading(true);
if (!documentData) {
return;
}
const bytes = await getFile(documentData);
const blob = new Blob([bytes], {
type: 'application/pdf',
});
const link = window.document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = fileName || 'document.pdf';
link.click();
window.URL.revokeObjectURL(link.href);
} catch (err) {
// We're just going to ignore this error and try to download the document
console.error(err);
} finally {
setIsLoading(false);
}
const documentBytes = Uint8Array.from(decodedDocument.split('').map((c) => c.charCodeAt(0)));
const blob = new Blob([documentBytes], {
type: 'application/pdf',
});
const link = window.document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = fileName || 'document.pdf';
link.click();
window.URL.revokeObjectURL(link.href);
};
return (
@ -57,8 +57,9 @@ export const DownloadButton = ({
type="button"
variant="outline"
className={className}
disabled={disabled || !document}
disabled={disabled || !documentData}
onClick={onDownloadClick}
loading={isLoading}
{...props}
>
<Download className="mr-2 h-5 w-5" />

View File

@ -38,9 +38,13 @@ export default async function CompletedSigningPage({
const [fields, recipient] = await Promise.all([
getFieldsForToken({ token }),
getRecipientByToken({ token }),
getRecipientByToken({ token }).catch(() => null),
]);
if (!recipient) {
return notFound();
}
const recipientName =
recipient.name ||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
@ -93,7 +97,7 @@ export default async function CompletedSigningPage({
<DownloadButton
className="flex-1"
fileName={document.title}
document={document.status === DocumentStatus.COMPLETED ? documentData.data : undefined}
documentData={documentData}
disabled={document.status !== DocumentStatus.COMPLETED}
/>
</div>

View File

@ -8,6 +8,7 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { FieldType } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
@ -36,19 +37,21 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
token,
}).catch(() => null),
getFieldsForToken({ token }),
getRecipientByToken({ token }),
getRecipientByToken({ token }).catch(() => null),
viewedDocument({ token }),
]);
if (!document || !document.documentData) {
if (!document || !document.documentData || !recipient) {
return notFound();
}
const { documentData } = document;
const user = await getServerComponentSession();
const documentDataUrl = await getFile(documentData)
.then((buffer) => Buffer.from(buffer).toString('base64'))
.then((data) => `data:application/pdf;base64,${data}`);
const documentUrl = `data:application/pdf;base64,${documentData.data}`;
const user = await getServerComponentSession();
return (
<SigningProvider email={recipient.email} fullName={recipient.name} signature={user?.signature}>
@ -69,7 +72,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
gradient
>
<CardContent className="p-2">
<LazyPDFViewer document={documentUrl} />
<LazyPDFViewer document={documentDataUrl} />
</CardContent>
</Card>

View File

@ -1,96 +0,0 @@
import { NextApiRequest, NextApiResponse } from 'next';
import formidable, { type File } from 'formidable';
import { readFileSync } from 'fs';
import { getServerSession } from '@documenso/lib/next-auth/get-server-session';
import { prisma } from '@documenso/prisma';
import { DocumentDataType, DocumentStatus } from '@documenso/prisma/client';
import {
TCreateDocumentRequestSchema,
TCreateDocumentResponseSchema,
} from '~/api/document/create/types';
export const config = {
api: {
bodyParser: false,
},
};
export type TFormidableCreateDocumentRequestSchema = {
file: File;
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<TCreateDocumentResponseSchema>,
) {
const user = await getServerSession({ req, res });
if (!user) {
return res.status(401).json({
error: 'Unauthorized',
});
}
try {
const form = formidable();
const { file } = await new Promise<TFormidableCreateDocumentRequestSchema>(
(resolve, reject) => {
form.parse(req, (err, fields, files) => {
if (err) {
reject(err);
}
// We had intended to do this with Zod but we can only validate it
// as a persistent file which does not include the properties that we
// need.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
resolve({ ...fields, ...files } as any);
});
},
);
const fileBuffer = readFileSync(file.filepath);
const bytes64 = fileBuffer.toString('base64');
const document = await prisma.document.create({
data: {
title: file.originalFilename ?? file.newFilename,
status: DocumentStatus.DRAFT,
userId: user.id,
documentData: {
create: {
type: DocumentDataType.BYTES_64,
data: bytes64,
initialData: bytes64,
},
},
created: new Date(),
},
});
return res.status(200).json({
id: document.id,
});
} catch (err) {
console.error(err);
return res.status(500).json({
error: 'Internal server error',
});
}
}
/**
* This is a hack to ensure that the types are correct.
*/
type FormidableSatisfiesCreateDocument =
keyof TCreateDocumentRequestSchema extends keyof TFormidableCreateDocumentRequestSchema
? true
: never;
true satisfies FormidableSatisfiesCreateDocument;

View File

@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { nanoid } from 'nanoid';
import { JWT, getToken } from 'next-auth/jwt';
import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
import { nanoid } from '@documenso/lib/universal/id';
import PostHogServerClient from '~/helpers/get-post-hog-server-client';

View File

@ -88,19 +88,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const bytes64 = readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64');
const { id: documentDataId } = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: bytes64,
initialData: bytes64,
},
});
const document = await prisma.document.create({
data: {
title: 'Documenso Supporter Pledge.pdf',
status: DocumentStatus.COMPLETED,
userId: user.id,
created: now,
documentData: {
create: {
type: DocumentDataType.BYTES_64,
data: bytes64,
initialData: bytes64,
},
},
documentDataId,
},
include: {
documentData: true,

1808
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -16,13 +16,12 @@
"worker:test": "tsup worker/index.ts --format esm"
},
"dependencies": {
"@documenso/tsconfig": "*",
"@documenso/tailwind-config": "*",
"@documenso/ui": "*",
"@react-email/components": "^0.0.7",
"nodemailer": "^6.9.3"
},
"devDependencies": {
"@documenso/tsconfig": "*",
"@documenso/tailwind-config": "*",
"@types/nodemailer": "^6.4.8",
"tsup": "^7.1.0"
}

View File

@ -4,8 +4,5 @@ const path = require('path');
module.exports = {
...baseConfig,
content: [
`templates/**/*.{ts,tsx}`,
`${path.join(require.resolve('@documenso/ui'), '..')}/**/*.{ts,tsx}`,
],
content: [`templates/**/*.{ts,tsx}`],
};

View File

@ -0,0 +1,5 @@
export const ONE_SECOND = 1000;
export const ONE_MINUTE = ONE_SECOND * 60;
export const ONE_HOUR = ONE_MINUTE * 60;
export const ONE_DAY = ONE_HOUR * 24;
export const ONE_WEEK = ONE_DAY * 7;

View File

@ -12,12 +12,15 @@
],
"scripts": {},
"dependencies": {
"@aws-sdk/s3-request-presigner": "^3.405.0",
"@aws-sdk/client-s3": "^3.405.0",
"@aws-sdk/client-s3": "^3.410.0",
"@aws-sdk/s3-request-presigner": "^3.410.0",
"@aws-sdk/signature-v4-crt": "^3.410.0",
"@documenso/email": "*",
"@documenso/prisma": "*",
"@next-auth/prisma-adapter": "1.0.7",
"@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1",
"@upstash/redis": "^1.20.6",
"bcrypt": "^5.1.0",
"luxon": "^3.4.0",

View File

@ -0,0 +1,19 @@
'use server';
import { prisma } from '@documenso/prisma';
import { DocumentDataType } from '@documenso/prisma/client';
export type CreateDocumentDataOptions = {
type: DocumentDataType;
data: string;
};
export const createDocumentData = async ({ type, data }: CreateDocumentDataOptions) => {
return await prisma.documentData.create({
data: {
type,
data,
initialData: data,
},
});
};

View File

@ -1,10 +1,19 @@
'use server';
import { prisma } from '@documenso/prisma';
export type CreateDocumentOptions = {
title: string;
userId: number;
fileName: string;
documentDataId: string;
};
export const createDocument = () => {
//
export const createDocument = async ({ userId, title, documentDataId }: CreateDocumentOptions) => {
return await prisma.document.create({
data: {
title,
documentDataId,
userId,
},
});
};

View File

@ -1,10 +1,13 @@
'use server';
import path from 'node:path';
import { PDFDocument } from 'pdf-lib';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
export type SealDocumentOptions = {
@ -23,7 +26,9 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
},
});
if (!document.documentData) {
const { documentData } = document;
if (!documentData) {
throw new Error(`Document ${document.id} has no document data`);
}
@ -55,7 +60,7 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
}
// !: Need to write the fields onto the document as a hard copy
const { data: pdfData } = document.documentData;
const pdfData = await getFile(documentData);
const doc = await PDFDocument.load(pdfData);
@ -65,17 +70,20 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
const pdfBytes = await doc.save();
await prisma.document.update({
const { name, ext } = path.parse(document.title);
const { data: newData } = await putFile({
name: `${name}_signed${ext}`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(Buffer.from(pdfBytes)),
});
await prisma.documentData.update({
where: {
id: document.id,
status: DocumentStatus.COMPLETED,
id: documentData.id,
},
data: {
documentData: {
update: {
data: Buffer.from(pdfBytes).toString('base64'),
},
},
data: newData,
},
});
};

View File

@ -1,8 +1,8 @@
import { nanoid } from 'nanoid';
import { prisma } from '@documenso/prisma';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { nanoid } from '../../universal/id';
export interface SetRecipientsForDocumentOptions {
userId: number;
documentId: number;

View File

@ -1,5 +1,8 @@
{
"extends": "@documenso/tsconfig/react-library.json",
"compilerOptions": {
"types": ["@documenso/tsconfig/process-env.d.ts"]
},
"include": ["**/*.ts", "**/*.tsx", "**/*.d.ts"],
"exclude": ["dist", "build", "node_modules"]
}

View File

@ -0,0 +1,5 @@
import { customAlphabet } from 'nanoid';
export const alphaid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 10);
export { nanoid } from 'nanoid';

View File

@ -0,0 +1,22 @@
import { match } from 'ts-pattern';
import { DocumentDataType } from '@documenso/prisma/client';
import { deleteS3File } from './server-actions';
export type DeleteFileOptions = {
type: DocumentDataType;
data: string;
};
export const deleteFile = async ({ type, data }: DeleteFileOptions) => {
return await match(type)
.with(DocumentDataType.S3_PATH, async () => deleteFileFromS3(data))
.otherwise(() => {
return;
});
};
const deleteFileFromS3 = async (key: string) => {
await deleteS3File(key);
};

View File

@ -0,0 +1,45 @@
import { base64 } from '@scure/base';
import { match } from 'ts-pattern';
import { DocumentDataType } from '@documenso/prisma/client';
import { getPresignGetUrl } from './server-actions';
export type GetFileOptions = {
type: DocumentDataType;
data: string;
};
export const getFile = 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 { url } = await getPresignGetUrl(key);
const buffer = await fetch(url, {
method: 'GET',
}).then(async (res) => res.arrayBuffer());
const binaryData = new Uint8Array(buffer);
return binaryData;
};

View File

@ -0,0 +1,53 @@
import { base64 } from '@scure/base';
import { match } from 'ts-pattern';
import { DocumentDataType } from '@documenso/prisma/client';
import { createDocumentData } from '../../server-only/document-data/create-document-data';
import { getPresignPostUrl } from './server-actions';
type File = {
name: string;
type: string;
arrayBuffer: () => Promise<ArrayBuffer>;
};
export const putFile = async (file: File) => {
const { type, data } = await match(process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT)
.with('s3', async () => putFileInS3(file))
.otherwise(async () => putFileInDatabase(file));
return await createDocumentData({ type, data });
};
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 { url, key } = await getPresignPostUrl(file.name, file.type);
const body = await file.arrayBuffer();
await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
},
body,
});
return {
type: DocumentDataType.S3_PATH,
data: key,
};
};

View File

@ -0,0 +1,104 @@
'use server';
import {
DeleteObjectCommand,
GetObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import slugify from '@sindresorhus/slugify';
import path from 'node:path';
import { ONE_HOUR, ONE_SECOND } from '../../constants/time';
import { getServerComponentSession } from '../../next-auth/get-server-session';
import { alphaid } from '../id';
export const getPresignPostUrl = async (fileName: string, contentType: string) => {
const client = getS3Client();
const user = await getServerComponentSession();
// Get the basename and extension for the file
const { name, ext } = path.parse(fileName);
let key = `${alphaid(12)}/${slugify(name)}${ext}`;
if (user) {
key = `${user.id}/${key}`;
}
const putObjectCommand = new PutObjectCommand({
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
Key: key,
ContentType: contentType,
});
const url = await getSignedUrl(client, putObjectCommand, {
expiresIn: ONE_HOUR / ONE_SECOND,
});
return { key, url };
};
export const getAbsolutePresignPostUrl = async (key: string) => {
const client = getS3Client();
const putObjectCommand = new PutObjectCommand({
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
Key: key,
});
const url = await getSignedUrl(client, putObjectCommand, {
expiresIn: ONE_HOUR / ONE_SECOND,
});
return { key, url };
};
export const getPresignGetUrl = async (key: string) => {
const client = getS3Client();
const getObjectCommand = new GetObjectCommand({
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
Key: key,
});
const url = await getSignedUrl(client, getObjectCommand, {
expiresIn: ONE_HOUR / ONE_SECOND,
});
return { key, url };
};
export const deleteS3File = async (key: string) => {
const client = getS3Client();
await client.send(
new DeleteObjectCommand({
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
Key: key,
}),
);
};
const getS3Client = () => {
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
throw new Error('Invalid upload transport');
}
const hasCredentials =
process.env.NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID &&
process.env.NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY;
return new S3Client({
endpoint: process.env.NEXT_PRIVATE_UPLOAD_ENDPOINT || undefined,
region: process.env.NEXT_PRIVATE_UPLOAD_REGION || 'us-east-1',
credentials: hasCredentials
? {
accessKeyId: String(process.env.NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID),
secretAccessKey: String(process.env.NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY),
}
: undefined,
});
};

View File

@ -0,0 +1,54 @@
import { base64 } from '@scure/base';
import { match } from 'ts-pattern';
import { DocumentDataType } from '@documenso/prisma/client';
import { getAbsolutePresignPostUrl } from './server-actions';
export type UpdateFileOptions = {
type: DocumentDataType;
oldData: string;
newData: string;
};
export const updateFile = async ({ type, oldData, newData }: UpdateFileOptions) => {
return await match(type)
.with(DocumentDataType.BYTES, () => updateFileWithBytes(newData))
.with(DocumentDataType.BYTES_64, () => updateFileWithBytes64(newData))
.with(DocumentDataType.S3_PATH, async () => updateFileWithS3(oldData, newData))
.exhaustive();
};
const updateFileWithBytes = (data: string) => {
return {
type: DocumentDataType.BYTES,
data,
};
};
const updateFileWithBytes64 = (data: string) => {
const encoder = new TextEncoder();
const binaryData = encoder.encode(data);
const asciiData = base64.encode(binaryData);
return {
type: DocumentDataType.BYTES_64,
data: asciiData,
};
};
const updateFileWithS3 = async (key: string, data: string) => {
const { url } = await getAbsolutePresignPostUrl(key);
await fetch(url, {
method: 'PUT',
body: data,
});
return {
type: DocumentDataType.S3_PATH,
data: key,
};
};

View File

@ -0,0 +1,23 @@
-- DropForeignKey
ALTER TABLE "DocumentData" DROP CONSTRAINT "DocumentData_documentId_fkey";
-- DropIndex
DROP INDEX "DocumentData_documentId_key";
-- AlterTable
ALTER TABLE "Document" ADD COLUMN "documentDataId" TEXT;
-- Reverse relation foreign key ids
UPDATE "Document" SET "documentDataId" = "DocumentData"."id" FROM "DocumentData" WHERE "Document"."id" = "DocumentData"."documentId";
-- AlterColumn
ALTER TABLE "Document" ALTER COLUMN "documentDataId" SET NOT NULL;
-- AlterTable
ALTER TABLE "DocumentData" DROP COLUMN "documentId";
-- CreateIndex
CREATE UNIQUE INDEX "Document_documentDataId_key" ON "Document"("documentDataId");
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_documentDataId_fkey" FOREIGN KEY ("documentDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -91,17 +91,20 @@ enum DocumentStatus {
}
model Document {
id Int @id @default(autoincrement())
created DateTime @default(now())
userId Int
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String
status DocumentStatus @default(DRAFT)
Recipient Recipient[]
Field Field[]
documentData DocumentData?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt @default(now())
id Int @id @default(autoincrement())
created DateTime @default(now())
userId Int
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String
status DocumentStatus @default(DRAFT)
Recipient Recipient[]
Field Field[]
documentDataId String
documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@unique([documentDataId])
}
enum DocumentDataType {
@ -115,11 +118,7 @@ model DocumentData {
type DocumentDataType
data String
initialData String
documentId Int
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
@@unique([documentId])
Document Document?
}
enum ReadStatus {

View File

@ -1,5 +1,6 @@
import { TRPCError } from '@trpc/server';
import { createDocument } from '@documenso/lib/server-only/document/create-document';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
@ -8,6 +9,7 @@ import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/s
import { authenticatedProcedure, procedure, router } from '../trpc';
import {
ZCreateDocumentMutationSchema,
ZGetDocumentByIdQuerySchema,
ZGetDocumentByTokenQuerySchema,
ZSendDocumentMutationSchema,
@ -22,11 +24,6 @@ export const documentRouter = router({
try {
const { id } = input;
console.log({
id,
userId: ctx.user.id,
});
return await getDocumentById({
id,
userId: ctx.user.id,
@ -58,6 +55,27 @@ export const documentRouter = router({
}
}),
createDocument: authenticatedProcedure
.input(ZCreateDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { title, documentDataId } = input;
return await createDocument({
userId: ctx.user.id,
title,
documentDataId,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to create this document. Please try again later.',
});
}
}),
setRecipientsForDocument: authenticatedProcedure
.input(ZSetRecipientsForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {

View File

@ -14,6 +14,13 @@ export const ZGetDocumentByTokenQuerySchema = z.object({
export type TGetDocumentByTokenQuerySchema = z.infer<typeof ZGetDocumentByTokenQuerySchema>;
export const ZCreateDocumentMutationSchema = z.object({
title: z.string().min(1),
documentDataId: z.string().min(1),
});
export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>;
export const ZSetRecipientsForDocumentMutationSchema = z.object({
documentId: z.number(),
recipients: z.array(

View File

@ -13,7 +13,7 @@ declare namespace NodeJS {
NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
NEXT_PRIVATE_UPLOAD_TRANSPORT?: 'database' | 's3';
NEXT_PUBLIC_UPLOAD_TRANSPORT?: 'database' | 's3';
NEXT_PRIVATE_UPLOAD_ENDPOINT?: string;
NEXT_PRIVATE_UPLOAD_REGION?: string;
NEXT_PRIVATE_UPLOAD_BUCKET?: string;

View File

@ -15,6 +15,7 @@
"lint": "eslint \"**/*.ts*\""
},
"devDependencies": {
"@documenso/tailwind-config": "*",
"@documenso/tsconfig": "*",
"@types/react": "18.2.18",
"@types/react-dom": "18.2.7",
@ -22,6 +23,7 @@
"typescript": "^5.1.6"
},
"dependencies": {
"@documenso/lib": "*",
"@radix-ui/react-accordion": "^1.1.1",
"@radix-ui/react-alert-dialog": "^1.0.3",
"@radix-ui/react-aspect-ratio": "^1.0.2",
@ -51,7 +53,6 @@
"class-variance-authority": "^0.6.0",
"clsx": "^1.2.1",
"cmdk": "^0.2.0",
"date-fns": "^2.30.0",
"framer-motion": "^10.12.8",
"lucide-react": "^0.214.0",
"next": "13.4.12",
@ -61,4 +62,4 @@
"tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5"
}
}
}

View File

@ -5,12 +5,12 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { Caveat } from 'next/font/google';
import { Check, ChevronsUpDown, Info } from 'lucide-react';
import { nanoid } from 'nanoid';
import { useFieldArray, useForm } from 'react-hook-form';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { nanoid } from '@documenso/lib/universal/id';
import { Field, FieldType, Recipient, SendStatus } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';

View File

@ -5,9 +5,9 @@ import React, { useId } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion';
import { Plus, Trash } from 'lucide-react';
import { nanoid } from 'nanoid';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { nanoid } from '@documenso/lib/universal/id';
import { Field, Recipient, SendStatus } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';

View File

@ -27,6 +27,12 @@
"NEXT_PRIVATE_NEXT_AUTH_SECRET",
"NEXT_PRIVATE_GOOGLE_CLIENT_ID",
"NEXT_PRIVATE_GOOGLE_CLIENT_SECRET",
"NEXT_PUBLIC_UPLOAD_TRANSPORT",
"NEXT_PRIVATE_UPLOAD_ENDPOINT",
"NEXT_PRIVATE_UPLOAD_REGION",
"NEXT_PRIVATE_UPLOAD_BUCKET",
"NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID",
"NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY",
"NEXT_PRIVATE_SMTP_TRANSPORT",
"NEXT_PRIVATE_MAILCHANNELS_API_KEY",
"NEXT_PRIVATE_MAILCHANNELS_ENDPOINT",