chore: fixed conflicts

Signed-off-by: Adithya Krishna <adi@documenso.com>
This commit is contained in:
Adithya Krishna
2023-12-01 13:03:48 +05:30
127 changed files with 4763 additions and 1007 deletions

View File

@ -0,0 +1 @@
export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY;

View File

@ -0,0 +1,2 @@
export const SETTINGS_PAGE_SHORTCUT = 'N+S';
export const DOCUMENTS_PAGE_SHORTCUT = 'N+D';

View File

@ -7,6 +7,8 @@ import GoogleProvider, { GoogleProfile } from 'next-auth/providers/google';
import { prisma } from '@documenso/prisma';
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
import { getUserByEmail } from '../server-only/user/get-user-by-email';
import { ErrorCode } from './error-codes';
@ -22,13 +24,19 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
totpCode: {
label: 'Two-factor Code',
type: 'input',
placeholder: 'Code from authenticator app',
},
backupCode: { label: 'Backup Code', type: 'input', placeholder: 'Two-factor backup code' },
},
authorize: async (credentials, _req) => {
if (!credentials) {
throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND);
}
const { email, password } = credentials;
const { email, password, backupCode, totpCode } = credentials;
const user = await getUserByEmail({ email }).catch(() => {
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
@ -44,6 +52,20 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
}
const is2faEnabled = isTwoFactorAuthenticationEnabled({ user });
if (is2faEnabled) {
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
if (!isValid) {
throw new Error(
totpCode
? ErrorCode.INCORRECT_TWO_FACTOR_CODE
: ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE,
);
}
}
return {
id: Number(user.id),
email: user.email,
@ -88,11 +110,13 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
merged.id = retrieved.id;
merged.name = retrieved.name;
merged.email = retrieved.email;
merged.emailVerified = retrieved.emailVerified;
}
if (
!merged.lastSignedIn ||
DateTime.fromISO(merged.lastSignedIn).plus({ hours: 1 }) <= DateTime.now()
merged.id &&
(!merged.lastSignedIn ||
DateTime.fromISO(merged.lastSignedIn).plus({ hours: 1 }) <= DateTime.now())
) {
merged.lastSignedIn = new Date().toISOString();
@ -111,6 +135,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
name: merged.name,
email: merged.email,
lastSignedIn: merged.lastSignedIn,
emailVerified: merged.emailVerified,
};
},
@ -122,6 +147,8 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
id: Number(token.id),
name: token.name,
email: token.email,
emailVerified:
typeof token.emailVerified === 'string' ? new Date(token.emailVerified) : null,
},
} satisfies Session;
}

View File

@ -8,4 +8,15 @@ export const ErrorCode = {
INCORRECT_EMAIL_PASSWORD: 'INCORRECT_EMAIL_PASSWORD',
USER_MISSING_PASSWORD: 'USER_MISSING_PASSWORD',
CREDENTIALS_NOT_FOUND: 'CREDENTIALS_NOT_FOUND',
INTERNAL_SEVER_ERROR: 'INTERNAL_SEVER_ERROR',
TWO_FACTOR_ALREADY_ENABLED: 'TWO_FACTOR_ALREADY_ENABLED',
TWO_FACTOR_SETUP_REQUIRED: 'TWO_FACTOR_SETUP_REQUIRED',
TWO_FACTOR_MISSING_SECRET: 'TWO_FACTOR_MISSING_SECRET',
TWO_FACTOR_MISSING_CREDENTIALS: 'TWO_FACTOR_MISSING_CREDENTIALS',
INCORRECT_TWO_FACTOR_CODE: 'INCORRECT_TWO_FACTOR_CODE',
INCORRECT_TWO_FACTOR_BACKUP_CODE: 'INCORRECT_TWO_FACTOR_BACKUP_CODE',
INCORRECT_IDENTITY_PROVIDER: 'INCORRECT_IDENTITY_PROVIDER',
INCORRECT_PASSWORD: 'INCORRECT_PASSWORD',
MISSING_ENCRYPTION_KEY: 'MISSING_ENCRYPTION_KEY',
MISSING_BACKUP_CODE: 'MISSING_BACKUP_CODE',
} as const;

View File

@ -0,0 +1,35 @@
'use server';
import { cache } from 'react';
import { getServerSession as getNextAuthServerSession } from 'next-auth';
import { prisma } from '@documenso/prisma';
import { NEXT_AUTH_OPTIONS } from './auth-options';
export const getServerComponentSession = cache(async () => {
const session = await getNextAuthServerSession(NEXT_AUTH_OPTIONS);
if (!session || !session.user?.email) {
return { user: null, session: null };
}
const user = await prisma.user.findFirstOrThrow({
where: {
email: session.user.email,
},
});
return { user, session };
});
export const getRequiredServerComponentSession = cache(async () => {
const { user, session } = await getServerComponentSession();
if (!user || !session) {
throw new Error('No session found');
}
return { user, session };
});

View File

@ -1,4 +1,6 @@
import { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from 'next';
'use server';
import type { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from 'next';
import { getServerSession as getNextAuthServerSession } from 'next-auth';
@ -26,29 +28,3 @@ export const getServerSession = async ({ req, res }: GetServerSessionOptions) =>
return { user, session };
};
export const getServerComponentSession = async () => {
const session = await getNextAuthServerSession(NEXT_AUTH_OPTIONS);
if (!session || !session.user?.email) {
return { user: null, session: null };
}
const user = await prisma.user.findFirstOrThrow({
where: {
email: session.user.email,
},
});
return { user, session };
};
export const getRequiredServerComponentSession = async () => {
const { user, session } = await getServerComponentSession();
if (!user || !session) {
throw new Error('No session found');
}
return { user, session };
};

View File

@ -11,6 +11,8 @@
"next-auth/"
],
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"clean": "rimraf node_modules"
},
"dependencies": {
@ -22,6 +24,8 @@
"@documenso/prisma": "*",
"@documenso/signing": "*",
"@next-auth/prisma-adapter": "1.0.7",
"@noble/ciphers": "0.4.0",
"@noble/hashes": "1.3.2",
"@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1",
@ -31,6 +35,7 @@
"nanoid": "^4.0.2",
"next": "14.0.0",
"next-auth": "4.24.3",
"oslo": "^0.17.0",
"pdf-lib": "^1.17.1",
"react": "18.2.0",
"remeda": "^1.27.1",

View File

@ -0,0 +1,48 @@
import { compare } from 'bcrypt';
import { prisma } from '@documenso/prisma';
import { User } from '@documenso/prisma/client';
import { ErrorCode } from '../../next-auth/error-codes';
import { validateTwoFactorAuthentication } from './validate-2fa';
type DisableTwoFactorAuthenticationOptions = {
user: User;
backupCode: string;
password: string;
};
export const disableTwoFactorAuthentication = async ({
backupCode,
user,
password,
}: DisableTwoFactorAuthenticationOptions) => {
if (!user.password) {
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
}
const isCorrectPassword = await compare(password, user.password);
if (!isCorrectPassword) {
throw new Error(ErrorCode.INCORRECT_PASSWORD);
}
const isValid = await validateTwoFactorAuthentication({ backupCode, user });
if (!isValid) {
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE);
}
await prisma.user.update({
where: {
id: user.id,
},
data: {
twoFactorEnabled: false,
twoFactorBackupCodes: null,
twoFactorSecret: null,
},
});
return true;
};

View File

@ -0,0 +1,47 @@
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { prisma } from '@documenso/prisma';
import { User } from '@documenso/prisma/client';
import { getBackupCodes } from './get-backup-code';
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
type EnableTwoFactorAuthenticationOptions = {
user: User;
code: string;
};
export const enableTwoFactorAuthentication = async ({
user,
code,
}: EnableTwoFactorAuthenticationOptions) => {
if (user.identityProvider !== 'DOCUMENSO') {
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
}
if (user.twoFactorEnabled) {
throw new Error(ErrorCode.TWO_FACTOR_ALREADY_ENABLED);
}
if (!user.twoFactorSecret) {
throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED);
}
const isValidToken = await verifyTwoFactorAuthenticationToken({ user, totpCode: code });
if (!isValidToken) {
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE);
}
const updatedUser = await prisma.user.update({
where: {
id: user.id,
},
data: {
twoFactorEnabled: true,
},
});
const recoveryCodes = getBackupCodes({ user: updatedUser });
return { recoveryCodes };
};

View File

@ -0,0 +1,38 @@
import { z } from 'zod';
import { User } from '@documenso/prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
import { symmetricDecrypt } from '../../universal/crypto';
interface GetBackupCodesOptions {
user: User;
}
const ZBackupCodeSchema = z.array(z.string());
export const getBackupCodes = ({ user }: GetBackupCodesOptions) => {
const key = DOCUMENSO_ENCRYPTION_KEY;
if (!user.twoFactorEnabled) {
throw new Error('User has not enabled 2FA');
}
if (!user.twoFactorBackupCodes) {
throw new Error('User has no backup codes');
}
const secret = Buffer.from(symmetricDecrypt({ key, data: user.twoFactorBackupCodes })).toString(
'utf-8',
);
const data = JSON.parse(secret);
const result = ZBackupCodeSchema.safeParse(data);
if (result.success) {
return result.data;
}
return null;
};

View File

@ -0,0 +1,17 @@
import { User } from '@documenso/prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
type IsTwoFactorAuthenticationEnabledOptions = {
user: User;
};
export const isTwoFactorAuthenticationEnabled = ({
user,
}: IsTwoFactorAuthenticationEnabledOptions) => {
return (
user.twoFactorEnabled &&
user.identityProvider === 'DOCUMENSO' &&
typeof DOCUMENSO_ENCRYPTION_KEY === 'string'
);
};

View File

@ -0,0 +1,76 @@
import { base32 } from '@scure/base';
import { compare } from 'bcrypt';
import crypto from 'crypto';
import { createTOTPKeyURI } from 'oslo/otp';
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { prisma } from '@documenso/prisma';
import { User } from '@documenso/prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
import { symmetricEncrypt } from '../../universal/crypto';
type SetupTwoFactorAuthenticationOptions = {
user: User;
password: string;
};
const ISSUER = 'Documenso';
export const setupTwoFactorAuthentication = async ({
user,
password,
}: SetupTwoFactorAuthenticationOptions) => {
const key = DOCUMENSO_ENCRYPTION_KEY;
if (!key) {
throw new Error(ErrorCode.MISSING_ENCRYPTION_KEY);
}
if (user.identityProvider !== 'DOCUMENSO') {
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
}
if (!user.password) {
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
}
const isCorrectPassword = await compare(password, user.password);
if (!isCorrectPassword) {
throw new Error(ErrorCode.INCORRECT_PASSWORD);
}
const secret = crypto.randomBytes(10);
const backupCodes = new Array(10)
.fill(null)
.map(() => crypto.randomBytes(5).toString('hex'))
.map((code) => `${code.slice(0, 5)}-${code.slice(5)}`.toUpperCase());
const accountName = user.email;
const uri = createTOTPKeyURI(ISSUER, accountName, secret);
const encodedSecret = base32.encode(secret);
await prisma.user.update({
where: {
id: user.id,
},
data: {
twoFactorEnabled: false,
twoFactorBackupCodes: symmetricEncrypt({
data: JSON.stringify(backupCodes),
key: key,
}),
twoFactorSecret: symmetricEncrypt({
data: encodedSecret,
key: key,
}),
},
});
return {
secret: encodedSecret,
uri,
};
};

View File

@ -0,0 +1,35 @@
import { User } from '@documenso/prisma/client';
import { ErrorCode } from '../../next-auth/error-codes';
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
import { verifyBackupCode } from './verify-backup-code';
type ValidateTwoFactorAuthenticationOptions = {
totpCode?: string;
backupCode?: string;
user: User;
};
export const validateTwoFactorAuthentication = async ({
backupCode,
totpCode,
user,
}: ValidateTwoFactorAuthenticationOptions) => {
if (!user.twoFactorEnabled) {
throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED);
}
if (!user.twoFactorSecret) {
throw new Error(ErrorCode.TWO_FACTOR_MISSING_SECRET);
}
if (totpCode) {
return await verifyTwoFactorAuthenticationToken({ user, totpCode });
}
if (backupCode) {
return await verifyBackupCode({ user, backupCode });
}
throw new Error(ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS);
};

View File

@ -0,0 +1,33 @@
import { base32 } from '@scure/base';
import { TOTPController } from 'oslo/otp';
import { User } from '@documenso/prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
import { symmetricDecrypt } from '../../universal/crypto';
const totp = new TOTPController();
type VerifyTwoFactorAuthenticationTokenOptions = {
user: User;
totpCode: string;
};
export const verifyTwoFactorAuthenticationToken = async ({
user,
totpCode,
}: VerifyTwoFactorAuthenticationTokenOptions) => {
const key = DOCUMENSO_ENCRYPTION_KEY;
if (!user.twoFactorSecret) {
throw new Error('user missing 2fa secret');
}
const secret = Buffer.from(symmetricDecrypt({ key, data: user.twoFactorSecret })).toString(
'utf-8',
);
const isValidToken = await totp.verify(totpCode, base32.decode(secret));
return isValidToken;
};

View File

@ -0,0 +1,18 @@
import { User } from '@documenso/prisma/client';
import { getBackupCodes } from './get-backup-code';
type VerifyBackupCodeParams = {
user: User;
backupCode: string;
};
export const verifyBackupCode = async ({ user, backupCode }: VerifyBackupCodeParams) => {
const userBackupCodes = await getBackupCodes({ user });
if (!userBackupCodes) {
throw new Error('User has no backup codes');
}
return userBackupCodes.includes(backupCode);
};

View File

@ -1,4 +1,4 @@
import { hashSync as bcryptHashSync } from 'bcrypt';
import { compareSync as bcryptCompareSync, hashSync as bcryptHashSync } from 'bcrypt';
import { SALT_ROUNDS } from '../../constants/auth';
@ -8,3 +8,7 @@ import { SALT_ROUNDS } from '../../constants/auth';
export const hashSync = (password: string) => {
return bcryptHashSync(password, SALT_ROUNDS);
};
export const compareSync = (password: string, hash: string) => {
return bcryptCompareSync(password, hash);
};

View File

@ -0,0 +1,56 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { ConfirmEmailTemplate } from '@documenso/email/templates/confirm-email';
import { prisma } from '@documenso/prisma';
export interface SendConfirmationEmailProps {
userId: number;
}
export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailProps) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
include: {
VerificationToken: {
orderBy: {
createdAt: 'desc',
},
take: 1,
},
},
});
const [verificationToken] = user.VerificationToken;
if (!verificationToken?.token) {
throw new Error('Verification token not found for the user');
}
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const confirmationLink = `${assetBaseUrl}/verify-email/${verificationToken.token}`;
const senderName = process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
const senderAdress = process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
const confirmationTemplate = createElement(ConfirmEmailTemplate, {
assetBaseUrl,
confirmationLink,
});
return mailer.sendMail({
to: {
address: user.email,
name: user.name || '',
},
from: {
name: senderName,
address: senderAdress,
},
subject: 'Please confirm your email',
html: render(confirmationTemplate),
text: render(confirmationTemplate, { plainText: true }),
});
};

View File

@ -94,6 +94,7 @@ export const completeDocumentWithToken = async ({
},
data: {
status: DocumentStatus.COMPLETED,
completedAt: new Date(),
},
});

View File

@ -0,0 +1,56 @@
import { prisma } from '@documenso/prisma';
export interface DuplicateDocumentByIdOptions {
id: number;
userId: number;
}
export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByIdOptions) => {
const document = await prisma.document.findUniqueOrThrow({
where: {
id,
userId: userId,
},
select: {
title: true,
userId: true,
documentData: {
select: {
data: true,
initialData: true,
type: true,
},
},
documentMeta: {
select: {
message: true,
subject: true,
},
},
},
});
const createdDocument = await prisma.document.create({
data: {
title: document.title,
User: {
connect: {
id: document.userId,
},
},
documentData: {
create: {
...document.documentData,
data: document.documentData.initialData,
},
},
documentMeta: {
create: {
...document.documentMeta,
},
},
},
});
return createdDocument.id;
};

View File

@ -1,4 +1,5 @@
import { match } from 'ts-pattern';
import { DateTime } from 'luxon';
import { P, match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { Document, Prisma, SigningStatus } from '@documenso/prisma/client';
@ -16,6 +17,7 @@ export interface FindDocumentsOptions {
column: keyof Omit<Document, 'document'>;
direction: 'asc' | 'desc';
};
period?: '' | '7d' | '14d' | '30d';
}
export const findDocuments = async ({
@ -25,6 +27,7 @@ export const findDocuments = async ({
page = 1,
perPage = 10,
orderBy,
period,
}: FindDocumentsOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
@ -35,14 +38,16 @@ export const findDocuments = async ({
const orderByColumn = orderBy?.column ?? 'createdAt';
const orderByDirection = orderBy?.direction ?? 'desc';
const termFilters = !term
? undefined
: ({
const termFilters = match(term)
.with(P.string.minLength(1), () => {
return {
title: {
contains: term,
mode: 'insensitive',
},
} as const);
} as const;
})
.otherwise(() => undefined);
const filters = match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)
.with(ExtendedDocumentStatus.ALL, () => ({
@ -113,12 +118,24 @@ export const findDocuments = async ({
}))
.exhaustive();
const whereClause = {
...termFilters,
...filters,
};
if (period) {
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
whereClause.createdAt = {
gte: startOfPeriod.toJSDate(),
};
}
const [data, count] = await Promise.all([
prisma.document.findMany({
where: {
...termFilters,
...filters,
},
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {

View File

@ -0,0 +1,99 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
export type ResendDocumentOptions = {
documentId: number;
userId: number;
recipients: number[];
};
export const resendDocument = async ({ documentId, userId, recipients }: ResendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const document = await prisma.document.findUnique({
where: {
id: documentId,
userId,
},
include: {
Recipient: {
where: {
id: {
in: recipients,
},
signingStatus: SigningStatus.NOT_SIGNED,
},
},
documentMeta: true,
},
});
const customEmail = document?.documentMeta;
if (!document) {
throw new Error('Document not found');
}
if (document.Recipient.length === 0) {
throw new Error('Document has no recipients');
}
if (document.status === DocumentStatus.DRAFT) {
throw new Error('Can not send draft document');
}
if (document.status === DocumentStatus.COMPLETED) {
throw new Error('Can not send completed document');
}
await Promise.all([
document.Recipient.map(async (recipient) => {
const { email, name } = recipient;
const customEmailTemplate = {
'signer.name': name,
'signer.email': email,
'document.name': document.title,
};
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const signDocumentLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`;
const template = createElement(DocumentInviteEmailTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
});
await mailer.sendMail({
to: {
address: email,
name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: 'Please sign this document',
html: render(template),
text: render(template, { plainText: true }),
});
}),
]);
};

View File

@ -105,7 +105,7 @@ export const extractDistinctUserId = (jwt: JWT | null, request: NextRequest): st
const config = extractPostHogConfig();
const email = jwt?.email;
const userId = jwt?.id.toString();
const userId = jwt?.id?.toString();
let fallbackDistinctId = nanoid();

View File

@ -0,0 +1,41 @@
import crypto from 'crypto';
import { prisma } from '@documenso/prisma';
import { ONE_HOUR } from '../../constants/time';
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
const IDENTIFIER = 'confirmation-email';
export const generateConfirmationToken = async ({ email }: { email: string }) => {
const token = crypto.randomBytes(20).toString('hex');
const user = await prisma.user.findFirst({
where: {
email: email,
},
});
if (!user) {
throw new Error('User not found');
}
const createdToken = await prisma.verificationToken.create({
data: {
identifier: IDENTIFIER,
token: token,
expires: new Date(Date.now() + ONE_HOUR),
user: {
connect: {
id: user.id,
},
},
},
});
if (!createdToken) {
throw new Error(`Failed to create the verification token`);
}
return sendConfirmationEmail({ userId: user.id });
};

View File

@ -0,0 +1,34 @@
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
export type GetUserMonthlyGrowthResult = Array<{
month: string;
count: number;
cume_count: number;
}>;
type GetUserMonthlyGrowthQueryResult = Array<{
month: Date;
count: bigint;
cume_count: bigint;
}>;
export const getUserMonthlyGrowth = async () => {
const result = await prisma.$queryRaw<GetUserMonthlyGrowthQueryResult>`
SELECT
DATE_TRUNC('month', "createdAt") AS "month",
COUNT("id") as "count",
SUM(COUNT("id")) OVER (ORDER BY DATE_TRUNC('month', "createdAt")) as "cume_count"
FROM "User"
GROUP BY "month"
ORDER BY "month" DESC
LIMIT 12
`;
return result.map((row) => ({
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
count: Number(row.count),
cume_count: Number(row.cume_count),
}));
};

View File

@ -0,0 +1,41 @@
import crypto from 'crypto';
import { prisma } from '@documenso/prisma';
import { ONE_HOUR } from '../../constants/time';
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
const IDENTIFIER = 'confirmation-email';
export const sendConfirmationToken = async ({ email }: { email: string }) => {
const token = crypto.randomBytes(20).toString('hex');
const user = await prisma.user.findFirst({
where: {
email: email,
},
});
if (!user) {
throw new Error('User not found');
}
const createdToken = await prisma.verificationToken.create({
data: {
identifier: IDENTIFIER,
token: token,
expires: new Date(Date.now() + ONE_HOUR),
user: {
connect: {
id: user.id,
},
},
},
});
if (!createdToken) {
throw new Error(`Failed to create the verification token`);
}
return sendConfirmationEmail({ userId: user.id });
};

View File

@ -0,0 +1,70 @@
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { sendConfirmationToken } from './send-confirmation-token';
export type VerifyEmailProps = {
token: string;
};
export const verifyEmail = async ({ token }: VerifyEmailProps) => {
const verificationToken = await prisma.verificationToken.findFirst({
include: {
user: true,
},
where: {
token,
},
});
if (!verificationToken) {
return null;
}
// check if the token is valid or expired
const valid = verificationToken.expires > new Date();
if (!valid) {
const mostRecentToken = await prisma.verificationToken.findFirst({
where: {
userId: verificationToken.userId,
},
orderBy: {
createdAt: 'desc',
},
});
// If there isn't a recent token or it's older than 1 hour, send a new token
if (
!mostRecentToken ||
DateTime.now().minus({ hours: 1 }).toJSDate() > mostRecentToken.createdAt
) {
await sendConfirmationToken({ email: verificationToken.user.email });
}
return valid;
}
const [updatedUser, deletedToken] = await prisma.$transaction([
prisma.user.update({
where: {
id: verificationToken.userId,
},
data: {
emailVerified: new Date(),
},
}),
prisma.verificationToken.deleteMany({
where: {
userId: verificationToken.userId,
},
}),
]);
if (!updatedUser || !deletedToken) {
throw new Error('Something went wrong while verifying your email. Please try again.');
}
return !!updatedUser && !!deletedToken;
};

View File

@ -0,0 +1,32 @@
import { xchacha20poly1305 } from '@noble/ciphers/chacha';
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/ciphers/utils';
import { managedNonce } from '@noble/ciphers/webcrypto/utils';
import { sha256 } from '@noble/hashes/sha256';
export type SymmetricEncryptOptions = {
key: string;
data: string;
};
export const symmetricEncrypt = ({ key, data }: SymmetricEncryptOptions) => {
const keyAsBytes = sha256(key);
const dataAsBytes = utf8ToBytes(data);
const chacha = managedNonce(xchacha20poly1305)(keyAsBytes); // manages nonces for you
return bytesToHex(chacha.encrypt(dataAsBytes));
};
export type SymmetricDecryptOptions = {
key: string;
data: string;
};
export const symmetricDecrypt = ({ key, data }: SymmetricDecryptOptions) => {
const keyAsBytes = sha256(key);
const dataAsBytes = hexToBytes(data);
const chacha = managedNonce(xchacha20poly1305)(keyAsBytes); // manages nonces for you
return chacha.decrypt(dataAsBytes);
};

View File

@ -0,0 +1,3 @@
export function megabytesToBytes(megabytes: number) {
return megabytes * 1000000;
}

View File

@ -10,7 +10,7 @@ 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 { getServerComponentSession } from '../../next-auth/get-server-component-session';
import { alphaid } from '../id';
export const getPresignPostUrl = async (fileName: string, contentType: string) => {