feat: migrate nextjs to rr7

This commit is contained in:
David Nguyen
2025-01-02 15:33:37 +11:00
parent 9183f668d3
commit 383b5f78f0
898 changed files with 31175 additions and 24615 deletions

View File

@ -1,6 +1,3 @@
import type { NextApiRequest } from 'next';
import type { RequestInternal } from 'next-auth';
import { z } from 'zod';
const ZIpSchema = z.string().ip();
@ -42,34 +39,14 @@ export type ApiRequestMetadata = {
};
};
export const extractNextApiRequestMetadata = (req: NextApiRequest): RequestMetadata => {
const parsedIp = ZIpSchema.safeParse(req.headers['x-forwarded-for'] || req.socket.remoteAddress);
export const extractRequestMetadata = (req: Request): RequestMetadata => {
const parsedIp = ZIpSchema.safeParse(req.headers.get('x-forwarded-for'));
const ipAddress = parsedIp.success ? parsedIp.data : undefined;
const userAgent = req.headers['user-agent'];
const userAgent = req.headers.get('user-agent');
return {
ipAddress,
userAgent,
};
};
export const extractNextAuthRequestMetadata = (
req: Pick<RequestInternal, 'body' | 'query' | 'headers' | 'method'>,
): RequestMetadata => {
return extractNextHeaderRequestMetadata(req.headers ?? {});
};
export const extractNextHeaderRequestMetadata = (
headers: Record<string, string>,
): RequestMetadata => {
const parsedIp = ZIpSchema.safeParse(headers?.['x-forwarded-for']);
const ipAddress = parsedIp.success ? parsedIp.data : undefined;
const userAgent = headers?.['user-agent'];
return {
ipAddress,
userAgent,
userAgent: userAgent ?? undefined,
};
};

View File

@ -1,13 +1,14 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import { env } from '../utils/env';
export const getBaseUrl = () => {
if (typeof window !== 'undefined') {
return '';
}
if (process.env.VERCEL_URL) {
return `https://${process.env.VERCEL_URL}`;
if (env('VERCEL_URL')) {
return `https://${env('VERCEL_URL')}`;
}
const webAppUrl = NEXT_PUBLIC_WEBAPP_URL();
@ -16,5 +17,5 @@ export const getBaseUrl = () => {
return webAppUrl;
}
return `http://localhost:${process.env.PORT ?? 3000}`;
return `http://localhost:${env('PORT') ?? 3000}`;
};

View File

@ -1,111 +0,0 @@
import { z } from 'zod';
import type { TFeatureFlagValue } from '@documenso/lib/client-only/providers/feature-flag.types';
import { ZFeatureFlagValueSchema } from '@documenso/lib/client-only/providers/feature-flag.types';
import { APP_BASE_URL } from '@documenso/lib/constants/app';
import { LOCAL_FEATURE_FLAGS, isFeatureFlagEnabled } from '@documenso/lib/constants/feature-flags';
/**
* Evaluate whether a flag is enabled for the current user.
*
* @param flag The flag to evaluate.
* @param options See `GetFlagOptions`.
* @returns Whether the flag is enabled, or the variant value of the flag.
*/
export const getFlag = async (
flag: string,
options?: GetFlagOptions,
): Promise<TFeatureFlagValue> => {
const requestHeaders = options?.requestHeaders ?? {};
delete requestHeaders['content-length'];
if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS[flag] ?? true;
}
const url = new URL(`${APP_BASE_URL()}/api/feature-flag/get`);
url.searchParams.set('flag', flag);
return await fetch(url, {
headers: {
...requestHeaders,
},
next: {
revalidate: 60,
},
})
.then(async (res) => res.json())
.then((res) => ZFeatureFlagValueSchema.parse(res))
.catch((err) => {
console.error(err);
return LOCAL_FEATURE_FLAGS[flag] ?? false;
});
};
/**
* Get all feature flags for the current user if possible.
*
* @param options See `GetFlagOptions`.
* @returns A record of flags and their values for the user derived from the headers.
*/
export const getAllFlags = async (
options?: GetFlagOptions,
): Promise<Record<string, TFeatureFlagValue>> => {
const requestHeaders = options?.requestHeaders ?? {};
delete requestHeaders['content-length'];
if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS;
}
const url = new URL(`${APP_BASE_URL()}/api/feature-flag/all`);
return fetch(url, {
headers: {
...requestHeaders,
},
next: {
revalidate: 60,
},
})
.then(async (res) => res.json())
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
.catch((err) => {
console.error(err);
return LOCAL_FEATURE_FLAGS;
});
};
/**
* Get all feature flags for anonymous users.
*
* @returns A record of flags and their values.
*/
export const getAllAnonymousFlags = async (): Promise<Record<string, TFeatureFlagValue>> => {
if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS;
}
const url = new URL(`${APP_BASE_URL()}/api/feature-flag/all`);
return fetch(url, {
next: {
revalidate: 60,
},
})
.then(async (res) => res.json())
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
.catch((err) => {
console.error(err);
return LOCAL_FEATURE_FLAGS;
});
};
interface GetFlagOptions {
/**
* The headers to attach to the request to evaluate flags.
*
* The authenticated user will be derived from the headers if possible.
*/
requestHeaders: Record<string, string>;
}

View File

@ -1,7 +1,6 @@
import { DocumentDataType } from '@prisma/client';
import { match } from 'ts-pattern';
import { DocumentDataType } from '@documenso/prisma/client';
import { deleteS3File } from './server-actions';
export type DeleteFileOptions = {

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

@ -1,8 +1,7 @@
import { DocumentDataType } from '@prisma/client';
import { base64 } from '@scure/base';
import { match } from 'ts-pattern';
import { DocumentDataType } from '@documenso/prisma/client';
export type GetFileOptions = {
type: DocumentDataType;
data: string;
@ -31,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,13 +1,15 @@
import { DocumentDataType } from '@prisma/client';
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 { 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;
@ -15,34 +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 = await getFlag('app_allow_encrypted_documents').catch(
() => false,
);
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;
};
/**
@ -70,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

@ -1,8 +1,3 @@
'use server';
import { headers } from 'next/headers';
import { NextRequest } from 'next/server';
import {
DeleteObjectCommand,
GetObjectCommand,
@ -10,44 +5,29 @@ import {
S3Client,
} from '@aws-sdk/client-s3';
import slugify from '@sindresorhus/slugify';
import { type JWT, getToken } from 'next-auth/jwt';
import { env } from 'next-runtime-env';
import path from 'node:path';
import { APP_BASE_URL } from '../../constants/app';
import { env } from '@documenso/lib/utils/env';
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');
let token: JWT | null = null;
try {
const baseUrl = APP_BASE_URL() ?? 'http://localhost:3000';
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({
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
ContentType: contentType,
});
@ -65,7 +45,7 @@ export const getAbsolutePresignPostUrl = async (key: string) => {
const { getSignedUrl: getS3SignedUrl } = await import('@aws-sdk/s3-request-presigner');
const putObjectCommand = new PutObjectCommand({
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
});
@ -77,15 +57,15 @@ export const getAbsolutePresignPostUrl = async (key: string) => {
};
export const getPresignGetUrl = async (key: string) => {
if (process.env.NEXT_PRIVATE_UPLOAD_DISTRIBUTION_DOMAIN) {
const distributionUrl = new URL(key, `${process.env.NEXT_PRIVATE_UPLOAD_DISTRIBUTION_DOMAIN}`);
if (env('NEXT_PRIVATE_UPLOAD_DISTRIBUTION_DOMAIN')) {
const distributionUrl = new URL(key, `${env('NEXT_PRIVATE_UPLOAD_DISTRIBUTION_DOMAIN')}`);
const { getSignedUrl: getCloudfrontSignedUrl } = await import('@aws-sdk/cloudfront-signer');
const url = getCloudfrontSignedUrl({
url: distributionUrl.toString(),
keyPairId: `${process.env.NEXT_PRIVATE_UPLOAD_DISTRIBUTION_KEY_ID}`,
privateKey: `${process.env.NEXT_PRIVATE_UPLOAD_DISTRIBUTION_KEY_CONTENTS}`,
keyPairId: `${env('NEXT_PRIVATE_UPLOAD_DISTRIBUTION_KEY_ID')}`,
privateKey: `${env('NEXT_PRIVATE_UPLOAD_DISTRIBUTION_KEY_CONTENTS')}`,
dateLessThan: new Date(Date.now() + ONE_HOUR).toISOString(),
});
@ -97,7 +77,7 @@ export const getPresignGetUrl = async (key: string) => {
const { getSignedUrl: getS3SignedUrl } = await import('@aws-sdk/s3-request-presigner');
const getObjectCommand = new GetObjectCommand({
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
});
@ -108,12 +88,37 @@ 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();
await client.send(
new DeleteObjectCommand({
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
}),
);
@ -127,17 +132,16 @@ const getS3Client = () => {
}
const hasCredentials =
process.env.NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID &&
process.env.NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY;
env('NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID') && env('NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY');
return new S3Client({
endpoint: process.env.NEXT_PRIVATE_UPLOAD_ENDPOINT || undefined,
forcePathStyle: process.env.NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE === 'true',
region: process.env.NEXT_PRIVATE_UPLOAD_REGION || 'us-east-1',
endpoint: env('NEXT_PRIVATE_UPLOAD_ENDPOINT') || undefined,
forcePathStyle: env('NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE') === 'true',
region: 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),
accessKeyId: String(env('NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID')),
secretAccessKey: String(env('NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY')),
}
: undefined,
});

View File

@ -1,8 +1,7 @@
import { DocumentDataType } from '@prisma/client';
import { base64 } from '@scure/base';
import { match } from 'ts-pattern';
import { DocumentDataType } from '@documenso/prisma/client';
export type UpdateFileOptions = {
type: DocumentDataType;
oldData: string;