Skip to content

Commit

Permalink
Merge pull request #12 from clemenscodes/develop
Browse files Browse the repository at this point in the history
contact form
  • Loading branch information
clemenscodes committed Jun 28, 2023
2 parents 93c0add + baa0532 commit 5912cad
Show file tree
Hide file tree
Showing 73 changed files with 3,688 additions and 1,010 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ apps/web/public/*
**/firebase-debug.*.log*
**/.firebase/
**/coverage
.vercel
7 changes: 7 additions & 0 deletions apps/web/.env.skeleton
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
SITE_URL="https://clemenshorn.com"
SMTP_HOST=smtp-relay.sendinblue.com
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=user
SMTP_PASS=pass
DKIM_KEY=dkim-key
3 changes: 3 additions & 0 deletions apps/web/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const nextConfig = {
defaultLocale: 'en',
locales: ['en', 'de'],
},
experimental: {
outputFileTracingIgnores: ['**canvas**'],
},
webpack(config) {
config.module.rules.push({
test: /index\.(js|mjs|jsx|ts|tsx)$/,
Expand Down
14 changes: 11 additions & 3 deletions apps/web/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,18 @@ import { site } from '@config';
import { Analytics } from '@vercel/analytics/react';
import { AppProps } from 'next/app';
import dynamic from 'next/dynamic';
import { Roboto_Condensed } from 'next/font/google';
import Head from 'next/head';

const ThemeProvider = dynamic(() => import('next-themes').then((mod) => mod.ThemeProvider));
const FontProvider = dynamic(() => import('@providers').then((mod) => mod.FontProvider));

const robotoCondensed = Roboto_Condensed({
weight: '300',
variable: '--font-sans',
subsets: ['latin'],
preload: false,
display: 'swap',
});

const App: React.FC<AppProps> = ({ Component, ...pageProps }) => {
return (
Expand All @@ -16,10 +24,10 @@ const App: React.FC<AppProps> = ({ Component, ...pageProps }) => {
<title>{site.title}</title>
</Head>
<ThemeProvider attribute='class' defaultTheme='dark' disableTransitionOnChange enableColorScheme>
<FontProvider>
<div className={`${robotoCondensed.variable} font-sans`}>
<Component {...pageProps} />
<Analytics />
</FontProvider>
</div>
</ThemeProvider>
</>
);
Expand Down
1 change: 1 addition & 0 deletions apps/web/pages/api/contact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { contactHandler as default } from '@api';
1 change: 0 additions & 1 deletion apps/web/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"root": "apps/web",
"outputPath": "dist/apps/web",
"assets": [
{
Expand Down
2 changes: 1 addition & 1 deletion libs/fonts/.eslintrc.json → libs/api/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"extends": ["plugin:@nx/react", "../../.eslintrc.json"],
"extends": ["../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
Expand Down
10 changes: 10 additions & 0 deletions libs/api/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/* eslint-disable */
export default {
displayName: 'api',
preset: '../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
};
12 changes: 6 additions & 6 deletions libs/fonts/project.json → libs/api/project.json
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
{
"name": "fonts",
"name": "api",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/fonts/src",
"sourceRoot": "libs/api/src",
"projectType": "library",
"tags": [],
"targets": {
"lint": {
"executor": "@nx/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["libs/fonts/**/*.{ts,tsx,js,jsx}"]
"lintFilePatterns": ["libs/api/**/*.ts"]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/fonts/jest.config.ts",
"jestConfig": "libs/api/jest.config.ts",
"passWithNoTests": true
},
"configurations": {
Expand All @@ -26,5 +25,6 @@
}
}
}
}
},
"tags": []
}
3 changes: 3 additions & 0 deletions libs/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './lib/rate-limit';
export * from './lib/ip-rate-limit';
export * from './lib/contact';
164 changes: 164 additions & 0 deletions libs/api/src/lib/contact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { ipRateLimit } from './ip-rate-limit';
import { Email } from '@components';
import { contact, i18nApi } from '@config';
import { render } from '@react-email/render';
import type { Locale } from '@types';
import { type ContactSchema, contactSchema } from '@utils';
import type { IncomingHttpHeaders } from 'http';
import { sanitize } from 'isomorphic-dompurify';
import { NextApiHandler } from 'next';
import { createTransport } from 'nodemailer';
import type { Options as MailOptions } from 'nodemailer/lib/mailer';
import type { Options as SMTPOptions, SentMessageInfo } from 'nodemailer/lib/smtp-transport';
import React from 'react';

export function assertIsContactData(data: unknown): asserts data is ContactSchema {
if (!contactSchema.safeParse(data).success) {
throw new Error('Invalid data');
}
}

export const getLocale = (headers: IncomingHttpHeaders): Locale => {
if (!('accept-language' in headers)) {
return 'en';
}

const acceptLanguage = headers['accept-language'];
if (!acceptLanguage) {
return 'en';
}

if (acceptLanguage.includes('de')) {
return 'de';
}

if (acceptLanguage.includes('es')) {
return 'es';
}

return 'en';
};

export const getSMTPOptions = (): SMTPOptions => {
const host = process.env['SMTP_HOST'];

if (!host) {
throw Error(`Host is undefined. Check process.env.SMTP_HOST`);
}

const port = (process.env['SMTP_PORT'] || 465) as number;
const secure = (process.env['SMTP_SECURE'] || true) as boolean;
const user = process.env['SMTP_USER'];

if (!user) {
throw Error(`SMTP user undefined. Check process.env.SMTP_HOST`);
}

const pass = process.env['SMTP_PASS'];

if (!pass) {
throw Error(`SMTP pass is undefined. Check process.env.SMTP_PASS`);
}

const privateKey = process.env['DKIM_KEY'];

if (!privateKey) {
throw Error(`DKIM private key is undefined. Check process.env.DKIM_KEY`);
}

const smtpTransportOptions: SMTPOptions = {
host,
port,
secure,
auth: { user, pass },
dkim: {
domainName: 'clemenshorn.com',
keySelector: 'mail',
cacheDir: '/tmp',
cacheTreshold: 100 * 1024,
privateKey,
},
requireTLS: true,
tls: {
rejectUnauthorized: true,
ciphers: 'SSLv3',
},
debug: true,
logger: true,
};

return smtpTransportOptions;
};

export const contactHandler: NextApiHandler = async (req, res) => {
const response = await ipRateLimit(req, res);

if (response.statusCode !== 200) {
return;
}

if (req.method !== 'POST') {
res.status(405).json({ error: 'Method not allowed' });
return;
}

const { body, headers } = req;

try {
assertIsContactData(body);
} catch (e) {
if (!(e instanceof Error)) {
res.status(500).json({ error: 'Internal Server Error' });
return;
}
res.status(405).json({ error: e.message });
return;
}

const locale: Locale = getLocale(headers);

const messages = i18nApi.get(locale);

if (!messages) {
res.status(500).json({ error: 'Internal Server Error' });
return;
}

const { name, email, subject, message } = body;
const cleanMessage = sanitize(message);
const cleanSubject = sanitize(subject);
const props = { subject: cleanSubject, message: cleanMessage };
const emailElement = React.createElement(Email, props);
const html = render(emailElement, { pretty: true });
const text = render(emailElement, { plainText: true });

const emailOptions: MailOptions = {
from: `${name} <${contact.email}>`,
to: contact.email,
replyTo: email,
cc: email,
subject: cleanSubject,
text,
html,
};

try {
const smtpTransportOptions = getSMTPOptions();

const transporter = createTransport(smtpTransportOptions);

await transporter.verify();

const message: SentMessageInfo = await transporter.sendMail(emailOptions);

if (!message.accepted) {
res.status(500).json({ error: messages.contact.error });
return;
}

res.status(200).json({ message: messages.contact.success });
} catch (e) {
console.error(`${messages.contact.error}: ${JSON.stringify(e)}`);
res.status(500).json({ error: messages.contact.error });
}
};
36 changes: 36 additions & 0 deletions libs/api/src/lib/ip-rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { initRateLimit, CountFn } from './rate-limit';
import type { NextApiRequest } from 'next';

export function getIP(request: NextApiRequest) {
if (!('x-forwarded-for' in request.headers)) {
return '127.0.0.1';
}

const xff = request.headers['x-forwarded-for'];

if (!xff || xff instanceof Array) {
return '127.0.0.1';
}

return xff ? xff.split(',')[0] : '';
}

export const ipRateLimit = initRateLimit((request) => ({
id: `ip:${getIP(request)}`,
count: increment,
limit: 5,
timeframe: 10,
}));

export const ipStore = new Map<string, number>();

export const increment: CountFn = async ({ key, limit }) => {
const oldRemaining = ipStore.get(key);
if (oldRemaining === undefined) {
ipStore.set(key, limit);
return limit;
}
const newRemaining = oldRemaining - 1;
ipStore.set(key, newRemaining);
return newRemaining;
};
Loading

1 comment on commit 5912cad

@vercel
Copy link

@vercel vercel bot commented on 5912cad Jun 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.