mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-02-16 10:42:08 +00:00
Restructure codebase to use src directory
This commit is contained in:
35
src/config/auth/cookie.ts
Normal file
35
src/config/auth/cookie.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextApiResponse } from 'next';
|
||||
import { serialize, parse } from 'cookie';
|
||||
import { SessionRequest } from './types';
|
||||
import { NODE_ENV, SESSION_MAX_AGE, SESSION_TOKEN_NAME } from '../env';
|
||||
|
||||
export function setTokenCookie(res: NextApiResponse, token: string) {
|
||||
const cookie = serialize(SESSION_TOKEN_NAME, token, {
|
||||
maxAge: SESSION_MAX_AGE,
|
||||
httpOnly: false,
|
||||
secure: NODE_ENV === 'production',
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
});
|
||||
|
||||
res.setHeader('Set-Cookie', cookie);
|
||||
}
|
||||
|
||||
export function removeTokenCookie(res: NextApiResponse) {
|
||||
const cookie = serialize(SESSION_TOKEN_NAME, '', { maxAge: -1, path: '/' });
|
||||
res.setHeader('Set-Cookie', cookie);
|
||||
}
|
||||
|
||||
export function parseCookies(req: SessionRequest) {
|
||||
// For API Routes we don't need to parse the cookies.
|
||||
if (req.cookies) return req.cookies;
|
||||
|
||||
// For pages we do need to parse the cookies.
|
||||
const cookie = req.headers?.cookie;
|
||||
return parse(cookie || '');
|
||||
}
|
||||
|
||||
export function getTokenCookie(req: SessionRequest) {
|
||||
const cookies = parseCookies(req);
|
||||
return cookies[SESSION_TOKEN_NAME];
|
||||
}
|
||||
24
src/config/auth/localStrat.ts
Normal file
24
src/config/auth/localStrat.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import findUserByUsername from '@/services/User/findUserByUsername';
|
||||
import Local from 'passport-local';
|
||||
import ServerError from '../util/ServerError';
|
||||
import { validatePassword } from './passwordFns';
|
||||
|
||||
const localStrat = new Local.Strategy(async (username, password, done) => {
|
||||
try {
|
||||
const user = await findUserByUsername(username);
|
||||
if (!user) {
|
||||
throw new ServerError('Username or password is incorrect.', 401);
|
||||
}
|
||||
|
||||
const isValidLogin = await validatePassword(user.hash, password);
|
||||
if (!isValidLogin) {
|
||||
throw new ServerError('Username or password is incorrect.', 401);
|
||||
}
|
||||
|
||||
done(null, { id: user.id, username: user.username });
|
||||
} catch (error) {
|
||||
done(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default localStrat;
|
||||
6
src/config/auth/passwordFns.ts
Normal file
6
src/config/auth/passwordFns.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import argon2 from 'argon2';
|
||||
|
||||
export const hashPassword = async (password: string) => argon2.hash(password);
|
||||
|
||||
export const validatePassword = async (hash: string, password: string) =>
|
||||
argon2.verify(hash, password);
|
||||
53
src/config/auth/session.ts
Normal file
53
src/config/auth/session.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NextApiResponse } from 'next';
|
||||
import Iron from '@hapi/iron';
|
||||
import {
|
||||
SessionRequest,
|
||||
BasicUserInfoSchema,
|
||||
UserSessionSchema,
|
||||
} from '@/config/auth/types';
|
||||
import { z } from 'zod';
|
||||
import { SESSION_MAX_AGE, SESSION_SECRET } from '@/config/env';
|
||||
import { setTokenCookie, getTokenCookie } from './cookie';
|
||||
import ServerError from '../util/ServerError';
|
||||
|
||||
export async function setLoginSession(
|
||||
res: NextApiResponse,
|
||||
session: z.infer<typeof BasicUserInfoSchema>,
|
||||
) {
|
||||
if (!SESSION_SECRET) {
|
||||
throw new ServerError('Authentication is not configured.', 500);
|
||||
}
|
||||
const createdAt = Date.now();
|
||||
const obj = { ...session, createdAt, maxAge: SESSION_MAX_AGE };
|
||||
const token = await Iron.seal(obj, SESSION_SECRET, Iron.defaults);
|
||||
|
||||
setTokenCookie(res, token);
|
||||
}
|
||||
|
||||
export async function getLoginSession(req: SessionRequest) {
|
||||
if (!SESSION_SECRET) {
|
||||
throw new ServerError('Authentication is not configured.', 500);
|
||||
}
|
||||
|
||||
const token = getTokenCookie(req);
|
||||
if (!token) {
|
||||
throw new ServerError('You are not logged in.', 401);
|
||||
}
|
||||
|
||||
const session = await Iron.unseal(token, SESSION_SECRET, Iron.defaults);
|
||||
|
||||
const parsed = UserSessionSchema.safeParse(session);
|
||||
|
||||
if (!parsed.success) {
|
||||
throw new ServerError('Session is invalid.', 401);
|
||||
}
|
||||
|
||||
const { createdAt, maxAge } = parsed.data;
|
||||
|
||||
const expiresAt = createdAt + maxAge * 1000;
|
||||
if (Date.now() > expiresAt) {
|
||||
throw new ServerError('Session expired', 401);
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
26
src/config/auth/types.ts
Normal file
26
src/config/auth/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import GetUserSchema from '@/services/User/schema/GetUserSchema';
|
||||
import { IncomingMessage } from 'http';
|
||||
import { NextApiRequest } from 'next';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const BasicUserInfoSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
username: z.string(),
|
||||
});
|
||||
|
||||
export const UserSessionSchema = BasicUserInfoSchema.merge(
|
||||
z.object({
|
||||
createdAt: z.number(),
|
||||
maxAge: z.number(),
|
||||
}),
|
||||
);
|
||||
|
||||
export interface UserExtendedNextApiRequest extends NextApiRequest {
|
||||
user?: z.infer<typeof GetUserSchema>;
|
||||
}
|
||||
|
||||
export type SessionRequest = IncomingMessage & {
|
||||
cookies: Partial<{
|
||||
[key: string]: string;
|
||||
}>;
|
||||
};
|
||||
19
src/config/cloudinary/index.ts
Normal file
19
src/config/cloudinary/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { v2 as cloudinary } from 'cloudinary';
|
||||
import { CloudinaryStorage } from 'multer-storage-cloudinary';
|
||||
|
||||
import { CLOUDINARY_CLOUD_NAME, CLOUDINARY_KEY, CLOUDINARY_SECRET } from '../env';
|
||||
|
||||
cloudinary.config({
|
||||
cloud_name: CLOUDINARY_CLOUD_NAME,
|
||||
api_key: CLOUDINARY_KEY,
|
||||
api_secret: CLOUDINARY_SECRET,
|
||||
});
|
||||
|
||||
// @ts-expect-error
|
||||
const storage = new CloudinaryStorage({ cloudinary, params: { folder: 'BeerApp' } });
|
||||
|
||||
/** Configuration object for Cloudinary image upload. */
|
||||
const cloudinaryConfig = { cloudinary, storage };
|
||||
|
||||
export default cloudinaryConfig;
|
||||
147
src/config/env/index.ts
vendored
Normal file
147
src/config/env/index.ts
vendored
Normal file
@@ -0,0 +1,147 @@
|
||||
/* eslint-disable prefer-destructuring */
|
||||
import { z } from 'zod';
|
||||
import { env } from 'process';
|
||||
import ServerError from '../util/ServerError';
|
||||
|
||||
import 'dotenv/config';
|
||||
|
||||
/**
|
||||
* Environment variables are validated at runtime to ensure that they are present and have
|
||||
* the correct type. This is done using the zod library.
|
||||
*/
|
||||
const envSchema = z.object({
|
||||
BASE_URL: z.string().url(),
|
||||
CLOUDINARY_CLOUD_NAME: z.string(),
|
||||
CLOUDINARY_KEY: z.string(),
|
||||
CLOUDINARY_SECRET: z.string(),
|
||||
CONFIRMATION_TOKEN_SECRET: z.string(),
|
||||
SESSION_SECRET: z.string(),
|
||||
SESSION_TOKEN_NAME: z.string(),
|
||||
SESSION_MAX_AGE: z.coerce.number().positive(),
|
||||
DATABASE_URL: z.string().url(),
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']),
|
||||
SPARKPOST_API_KEY: z.string(),
|
||||
SPARKPOST_SENDER_ADDRESS: z.string().email(),
|
||||
});
|
||||
|
||||
const parsed = envSchema.safeParse(env);
|
||||
|
||||
if (!parsed.success) {
|
||||
throw new ServerError('Invalid environment variables', 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base URL of the application.
|
||||
*
|
||||
* @example
|
||||
* 'https://example.com';
|
||||
*/
|
||||
export const BASE_URL = parsed.data.BASE_URL;
|
||||
|
||||
/**
|
||||
* Cloudinary cloud name.
|
||||
*
|
||||
* @example
|
||||
* 'my-cloud';
|
||||
*
|
||||
* @see https://cloudinary.com/documentation/cloudinary_references
|
||||
* @see https://cloudinary.com/console
|
||||
*/
|
||||
|
||||
export const CLOUDINARY_CLOUD_NAME = parsed.data.CLOUDINARY_CLOUD_NAME;
|
||||
|
||||
/**
|
||||
* Cloudinary API key.
|
||||
*
|
||||
* @example
|
||||
* '123456789012345';
|
||||
*
|
||||
* @see https://cloudinary.com/documentation/cloudinary_references
|
||||
* @see https://cloudinary.com/console
|
||||
*/
|
||||
export const CLOUDINARY_KEY = parsed.data.CLOUDINARY_KEY;
|
||||
|
||||
/**
|
||||
* Cloudinary API secret.
|
||||
*
|
||||
* @example
|
||||
* 'abcdefghijklmnopqrstuvwxyz123456';
|
||||
*
|
||||
* @see https://cloudinary.com/documentation/cloudinary_references
|
||||
* @see https://cloudinary.com/console
|
||||
*/
|
||||
export const CLOUDINARY_SECRET = parsed.data.CLOUDINARY_SECRET;
|
||||
|
||||
/**
|
||||
* Secret key for signing confirmation tokens.
|
||||
*
|
||||
* @example
|
||||
* 'abcdefghijklmnopqrstuvwxyz123456';
|
||||
*
|
||||
* @see README.md for instructions on generating a secret key.
|
||||
*/
|
||||
export const CONFIRMATION_TOKEN_SECRET = parsed.data.CONFIRMATION_TOKEN_SECRET;
|
||||
|
||||
/**
|
||||
* Secret key for signing session cookies.
|
||||
*
|
||||
* @example
|
||||
* 'abcdefghijklmnopqrstuvwxyz123456';
|
||||
*
|
||||
* @see README.md for instructions on generating a secret key.
|
||||
*/
|
||||
export const SESSION_SECRET = parsed.data.SESSION_SECRET;
|
||||
|
||||
/**
|
||||
* Name of the session cookie.
|
||||
*
|
||||
* @example
|
||||
* 'my-app-session';
|
||||
*/
|
||||
export const SESSION_TOKEN_NAME = parsed.data.SESSION_TOKEN_NAME;
|
||||
|
||||
/**
|
||||
* Maximum age of the session cookie in milliseconds.
|
||||
*
|
||||
* @example
|
||||
* '86400000'; // 24 hours
|
||||
*/
|
||||
export const SESSION_MAX_AGE = parsed.data.SESSION_MAX_AGE;
|
||||
|
||||
/**
|
||||
* URL of the CockroachDB database. CockroachDB uses the PostgreSQL wire protocol.
|
||||
*
|
||||
* @example
|
||||
* 'postgres://username:password@localhost/my-database';
|
||||
*/
|
||||
export const DATABASE_URL = parsed.data.DATABASE_URL;
|
||||
|
||||
/**
|
||||
* Node environment.
|
||||
*
|
||||
* @example
|
||||
* 'production';
|
||||
*
|
||||
* @see https://nodejs.org/api/process.html#process_process_env
|
||||
*/
|
||||
export const NODE_ENV = parsed.data.NODE_ENV;
|
||||
|
||||
/**
|
||||
* SparkPost API key.
|
||||
*
|
||||
* @example
|
||||
* 'abcdefghijklmnopqrstuvwxyz123456';
|
||||
*
|
||||
* @see https://app.sparkpost.com/account/api-keys
|
||||
*/
|
||||
export const SPARKPOST_API_KEY = parsed.data.SPARKPOST_API_KEY;
|
||||
|
||||
/**
|
||||
* Sender email address for SparkPost.
|
||||
*
|
||||
* @example
|
||||
* 'noreply@example.com';
|
||||
*
|
||||
* @see https://app.sparkpost.com/domains/list/sending
|
||||
*/
|
||||
export const SPARKPOST_SENDER_ADDRESS = parsed.data.SPARKPOST_SENDER_ADDRESS;
|
||||
23
src/config/jwt/index.ts
Normal file
23
src/config/jwt/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { BasicUserInfoSchema } from '@/config/auth/types';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { z } from 'zod';
|
||||
import { CONFIRMATION_TOKEN_SECRET } from '../env';
|
||||
|
||||
type User = z.infer<typeof BasicUserInfoSchema>;
|
||||
|
||||
export const generateConfirmationToken = (user: User) => {
|
||||
const token = jwt.sign(user, CONFIRMATION_TOKEN_SECRET, { expiresIn: '30m' });
|
||||
return token;
|
||||
};
|
||||
|
||||
export const verifyConfirmationToken = (token: string) => {
|
||||
const decoded = jwt.verify(token, CONFIRMATION_TOKEN_SECRET);
|
||||
|
||||
const parsed = BasicUserInfoSchema.safeParse(decoded);
|
||||
|
||||
if (!parsed.success) {
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
};
|
||||
41
src/config/nextConnect/NextConnectOptions.ts
Normal file
41
src/config/nextConnect/NextConnectOptions.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type { RequestHandler } from 'next-connect/dist/types/node';
|
||||
import type { HandlerOptions } from 'next-connect/dist/types/types';
|
||||
import { z } from 'zod';
|
||||
import logger from '../pino/logger';
|
||||
|
||||
import ServerError from '../util/ServerError';
|
||||
import { NODE_ENV } from '../env';
|
||||
|
||||
type NextConnectOptionsT = HandlerOptions<
|
||||
RequestHandler<
|
||||
NextApiRequest,
|
||||
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
|
||||
>
|
||||
>;
|
||||
|
||||
const NextConnectOptions: NextConnectOptionsT = {
|
||||
onNoMatch(req, res) {
|
||||
res.status(405).json({
|
||||
message: 'Method not allowed.',
|
||||
statusCode: 405,
|
||||
success: false,
|
||||
});
|
||||
},
|
||||
onError(error, req, res) {
|
||||
if (NODE_ENV !== 'production') {
|
||||
logger.error(error);
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : 'Internal server error.';
|
||||
const statusCode = error instanceof ServerError ? error.statusCode : 500;
|
||||
res.status(statusCode).json({
|
||||
message,
|
||||
statusCode,
|
||||
success: false,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default NextConnectOptions;
|
||||
31
src/config/nextConnect/middleware/checkIfBeerPostOwner.ts
Normal file
31
src/config/nextConnect/middleware/checkIfBeerPostOwner.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { UserExtendedNextApiRequest } from '@/config/auth/types';
|
||||
import ServerError from '@/config/util/ServerError';
|
||||
import getBeerPostById from '@/services/BeerPost/getBeerPostById';
|
||||
import { NextApiResponse } from 'next';
|
||||
import { NextHandler } from 'next-connect';
|
||||
|
||||
interface CheckIfBeerPostOwnerRequest extends UserExtendedNextApiRequest {
|
||||
query: { id: string };
|
||||
}
|
||||
|
||||
const checkIfBeerPostOwner = async <RequestType extends CheckIfBeerPostOwnerRequest>(
|
||||
req: RequestType,
|
||||
res: NextApiResponse,
|
||||
next: NextHandler,
|
||||
) => {
|
||||
const { id } = req.query;
|
||||
const user = req.user!;
|
||||
const beerPost = await getBeerPostById(id);
|
||||
|
||||
if (!beerPost) {
|
||||
throw new ServerError('Beer post not found', 404);
|
||||
}
|
||||
|
||||
if (beerPost.postedBy.id !== user.id) {
|
||||
throw new ServerError('You are not authorized to edit this beer post', 403);
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
export default checkIfBeerPostOwner;
|
||||
25
src/config/nextConnect/middleware/getCurrentUser.ts
Normal file
25
src/config/nextConnect/middleware/getCurrentUser.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextApiResponse } from 'next';
|
||||
import { NextHandler } from 'next-connect';
|
||||
import findUserById from '@/services/User/findUserById';
|
||||
import ServerError from '@/config/util/ServerError';
|
||||
import { getLoginSession } from '../../auth/session';
|
||||
import { UserExtendedNextApiRequest } from '../../auth/types';
|
||||
|
||||
/** Get the current user from the session. Adds the user to the request object. */
|
||||
const getCurrentUser = async (
|
||||
req: UserExtendedNextApiRequest,
|
||||
res: NextApiResponse,
|
||||
next: NextHandler,
|
||||
) => {
|
||||
const session = await getLoginSession(req);
|
||||
const user = await findUserById(session?.id);
|
||||
|
||||
if (!user) {
|
||||
throw new ServerError('User is not logged in.', 401);
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
return next();
|
||||
};
|
||||
|
||||
export default getCurrentUser;
|
||||
49
src/config/nextConnect/middleware/validateRequest.ts
Normal file
49
src/config/nextConnect/middleware/validateRequest.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import ServerError from '@/config/util/ServerError';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { NextHandler } from 'next-connect';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Middleware to validate the request body and/or query against a zod schema.
|
||||
*
|
||||
* @example
|
||||
* const handler = nextConnect(NextConnectConfig).post(
|
||||
* validateRequest({ bodySchema: BeerPostValidationSchema }),
|
||||
* getCurrentUser,
|
||||
* createBeerPost,
|
||||
* );
|
||||
*
|
||||
* @param args
|
||||
* @param args.bodySchema The body schema to validate against.
|
||||
* @param args.querySchema The query schema to validate against.
|
||||
* @throws ServerError with status code 400 if the request body or query is invalid.
|
||||
*/
|
||||
const validateRequest =
|
||||
({
|
||||
bodySchema,
|
||||
querySchema,
|
||||
}: {
|
||||
bodySchema?: z.ZodSchema<any>;
|
||||
querySchema?: z.ZodSchema<any>;
|
||||
}) =>
|
||||
async (req: NextApiRequest, res: NextApiResponse, next: NextHandler) => {
|
||||
if (bodySchema) {
|
||||
const parsed = bodySchema.safeParse(JSON.parse(JSON.stringify(req.body)));
|
||||
if (!parsed.success) {
|
||||
throw new ServerError('Invalid request body.', 400);
|
||||
}
|
||||
req.body = parsed.data;
|
||||
}
|
||||
|
||||
if (querySchema) {
|
||||
const parsed = querySchema.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
throw new ServerError('Invalid request query.', 400);
|
||||
}
|
||||
req.query = parsed.data;
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
export default validateRequest;
|
||||
5
src/config/pino/logger.ts
Normal file
5
src/config/pino/logger.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import pino from 'pino';
|
||||
|
||||
const logger = pino();
|
||||
|
||||
export default logger;
|
||||
6
src/config/sparkpost/client.ts
Normal file
6
src/config/sparkpost/client.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import SparkPost from 'sparkpost';
|
||||
import { SPARKPOST_API_KEY } from '../env';
|
||||
|
||||
const client = new SparkPost(SPARKPOST_API_KEY);
|
||||
|
||||
export default client;
|
||||
20
src/config/sparkpost/sendEmail.ts
Normal file
20
src/config/sparkpost/sendEmail.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { SPARKPOST_SENDER_ADDRESS } from '../env';
|
||||
import client from './client';
|
||||
|
||||
interface EmailParams {
|
||||
address: string;
|
||||
text: string;
|
||||
html: string;
|
||||
subject: string;
|
||||
}
|
||||
|
||||
const sendEmail = async ({ address, text, html, subject }: EmailParams) => {
|
||||
const from = SPARKPOST_SENDER_ADDRESS;
|
||||
|
||||
await client.transmissions.send({
|
||||
content: { from, html, subject, text },
|
||||
recipients: [{ address }],
|
||||
});
|
||||
};
|
||||
|
||||
export default sendEmail;
|
||||
8
src/config/util/ServerError.ts
Normal file
8
src/config/util/ServerError.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
class ServerError extends Error {
|
||||
constructor(message: string, public statusCode: number) {
|
||||
super(message);
|
||||
this.name = 'ServerError';
|
||||
}
|
||||
}
|
||||
|
||||
export default ServerError;
|
||||
Reference in New Issue
Block a user