Move next js project to archive (#207)

This commit is contained in:
Aaron Po
2026-04-20 02:30:25 -04:00
committed by GitHub
parent 92ec16ce93
commit d47e3ed7f0
347 changed files with 0 additions and 0 deletions

View 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];
}

View File

@@ -0,0 +1,24 @@
import Local from 'passport-local';
import { findUserByUsernameService } from '@/services/users/auth';
import ServerError from '../util/ServerError';
import { validatePassword } from './passwordFns';
const localStrat = new Local.Strategy(async (username, password, done) => {
try {
const user = await findUserByUsernameService({ 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;

View 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);

View 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;
}

View File

@@ -0,0 +1,26 @@
import GetUserSchema from '@/services/users/auth/schema/GetUserSchema';
import { IncomingMessage } from 'http';
import { NextApiRequest } from 'next';
import { z } from 'zod';
export const BasicUserInfoSchema = z.object({
id: z.string().cuid(),
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;
}>;
};

View File

@@ -0,0 +1,95 @@
/* eslint-disable no-underscore-dangle */
import type { StorageEngine } from 'multer';
import type { UploadApiOptions, UploadApiResponse, v2 as cloudinary } from 'cloudinary';
import type { Request } from 'express';
/**
* Represents a storage engine for uploading files to Cloudinary.
*
* @example
* const storage = new CloudinaryStorage({
* cloudinary,
* params: {
* folder: 'my-folder',
* allowed_formats: ['jpg', 'png'],
* },
* });
*/
class CloudinaryStorage implements StorageEngine {
private cloudinary: typeof cloudinary;
private params: UploadApiOptions;
/**
* Creates an instance of CloudinaryStorage.
*
* @param options - The options for configuring the Cloudinary storage engine.
* @param options.cloudinary - The Cloudinary instance.
* @param options.params - The parameters for uploading files to Cloudinary.
*/
constructor(options: { cloudinary: typeof cloudinary; params: UploadApiOptions }) {
this.cloudinary = options.cloudinary;
this.params = options.params;
}
/**
* Removes the file from Cloudinary.
*
* @param req - The request object.
* @param file - The file to be removed.
* @param callback - The callback function to be called if an error occurs.
*/
_removeFile(req: Request, file: Express.Multer.File, callback: (error: Error) => void) {
this.cloudinary.uploader.destroy(file.filename, { invalidate: true }, callback);
}
/**
* Handles the file upload to Cloudinary.
*
* @param req - The request object.
* @param file - The file to be uploaded.
* @param callback - The callback function to be called after the file is uploaded.
*/
_handleFile(
req: Request,
file: Express.Multer.File,
callback: (error?: unknown, info?: Partial<Express.Multer.File>) => void,
) {
this.uploadFile(file)
.then((cloudResponse) => {
callback(null, {
path: cloudResponse.secure_url,
size: cloudResponse.bytes,
filename: cloudResponse.public_id,
});
})
.catch((error) => {
callback(error);
});
}
/**
* Uploads a file to Cloudinary.
*
* @param file - The file to be uploaded.
* @returns A promise that resolves to the upload response.
*/
private uploadFile(file: Express.Multer.File): Promise<UploadApiResponse> {
return new Promise((resolve, reject) => {
const stream = this.cloudinary.uploader.upload_stream(
this.params,
(err, response) => {
if (err != null) {
return reject(err);
}
return resolve(response!);
},
);
file.stream.pipe(stream);
});
}
}
export default CloudinaryStorage;

View File

@@ -0,0 +1,11 @@
import { cloudinary } from '..';
/**
* Deletes an image from Cloudinary.
*
* @param path - The cloudinary path of the image to be deleted.
* @returns A promise that resolves when the image is successfully deleted.
*/
const deleteImage = (path: string) => cloudinary.uploader.destroy(path);
export default deleteImage;

View File

@@ -0,0 +1,23 @@
import { v2 as cloudinary } from 'cloudinary';
import {
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
CLOUDINARY_KEY,
CLOUDINARY_SECRET,
NODE_ENV,
} from '../env';
import CloudinaryStorage from './CloudinaryStorage';
cloudinary.config({
cloud_name: NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
api_key: CLOUDINARY_KEY,
api_secret: CLOUDINARY_SECRET,
});
/** Cloudinary storage instance. */
const storage = new CloudinaryStorage({
cloudinary,
params: { folder: NODE_ENV === 'production' ? 'biergarten' : 'biergarten-dev' },
});
export { cloudinary, storage };

View File

@@ -0,0 +1,210 @@
/* 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(),
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME: z.string(),
CLOUDINARY_KEY: z.string(),
CLOUDINARY_SECRET: z.string(),
RESET_PASSWORD_TOKEN_SECRET: z.string(),
CONFIRMATION_TOKEN_SECRET: z.string(),
SESSION_SECRET: z.string(),
SESSION_TOKEN_NAME: z.string(),
SESSION_MAX_AGE: z.coerce.number().positive(),
POSTGRES_PRISMA_URL: z.string().url(),
POSTGRES_URL_NON_POOLING: z.string().url(),
SHADOW_DATABASE_URL: z.string().url(),
NODE_ENV: z.enum(['development', 'production', 'test']),
SPARKPOST_API_KEY: z.string(),
SPARKPOST_SENDER_ADDRESS: z.string().email(),
MAPBOX_ACCESS_TOKEN: z.string(),
ADMIN_PASSWORD: z.string().regex(/^(?=.*[A-Z])(?=.*[0-9])(?=.*[^a-zA-Z0-9]).{8,}$/),
});
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 NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME =
parsed.data.NEXT_PUBLIC_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 reset password tokens.
*
* @example
* 'abcdefghijklmnopqrstuvwxyz123456';
*
* @see README.md for instructions on generating a secret key.
*/
export const RESET_PASSWORD_TOKEN_SECRET = parsed.data.RESET_PASSWORD_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 seconds.
*
* @example
* '86400000'; // 24 hours
*/
export const SESSION_MAX_AGE = parsed.data.SESSION_MAX_AGE;
/**
* PostgreSQL connection URL for Prisma taken from Neon.
*
* @example
* 'postgresql://user:password@host:5432/database';
*
* @see https://neon.tech/docs/guides/prisma
*/
export const POSTGRES_PRISMA_URL = parsed.data.POSTGRES_PRISMA_URL;
/**
* Non-pooling PostgreSQL connection URL taken from Neon.
*
* @example
* 'postgresql://user:password@host:5432/database';
*
* @see https://neon.tech/docs/guides/prisma
*/
export const POSTGRES_URL_NON_POOLING = parsed.data.POSTGRES_URL_NON_POOLING;
/**
* The URL of another Neon PostgreSQL database to shadow for migrations.
*
* @example
* 'postgresql://user:password@host:5432/database';
*
* @see https://neon.tech/docs/guides/prisma-migrate
*/
export const SHADOW_DATABASE_URL = parsed.data.SHADOW_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;
/**
* Your Mapbox access token.
*
* @example
* 'pk.abcdefghijklmnopqrstuvwxyz123456';
*
* @see https://docs.mapbox.com/help/how-mapbox-works/access-tokens/
*/
export const MAPBOX_ACCESS_TOKEN = parsed.data.MAPBOX_ACCESS_TOKEN;
/**
* Admin password for seeding the database.
*
* @example
* 'abcdefghijklmnopqrstuvwxyz123456';
*
* @see README.md for instructions on generating a secret key.
*/
export const ADMIN_PASSWORD = parsed.data.ADMIN_PASSWORD;

View File

@@ -0,0 +1,58 @@
import { BasicUserInfoSchema } from '@/config/auth/types';
import jwt, { JsonWebTokenError } from 'jsonwebtoken';
import { z } from 'zod';
import { CONFIRMATION_TOKEN_SECRET, RESET_PASSWORD_TOKEN_SECRET } from '../env';
import ServerError from '../util/ServerError';
export const generateConfirmationToken = (user: z.infer<typeof BasicUserInfoSchema>) => {
return jwt.sign(user, CONFIRMATION_TOKEN_SECRET, { expiresIn: '3m' });
};
export const verifyConfirmationToken = async (token: string) => {
try {
const decoded = jwt.verify(token, CONFIRMATION_TOKEN_SECRET);
const parsed = BasicUserInfoSchema.safeParse(decoded);
if (!parsed.success) {
throw new ServerError('Invalid token.', 401);
}
return parsed.data;
} catch (error) {
if (error instanceof Error && error.message === 'jwt expired') {
throw new ServerError(
'Your confirmation token is expired. Please generate a new one.',
401,
);
}
throw new ServerError('Something went wrong', 500);
}
};
export const generateResetPasswordToken = (user: z.infer<typeof BasicUserInfoSchema>) => {
return jwt.sign(user, RESET_PASSWORD_TOKEN_SECRET, { expiresIn: '5m' });
};
export const verifyResetPasswordToken = async (token: string) => {
try {
const decoded = jwt.verify(token, RESET_PASSWORD_TOKEN_SECRET);
const parsed = BasicUserInfoSchema.safeParse(decoded);
if (!parsed.success) {
throw new Error('Invalid token');
}
return parsed.data;
} catch (error) {
if (error instanceof JsonWebTokenError) {
throw new ServerError(
'Your reset password token is invalid. Please generate a new one.',
401,
);
}
throw new ServerError('Something went wrong', 500);
}
};

View File

@@ -0,0 +1,12 @@
import mbxGeocoding from '@mapbox/mapbox-sdk/services/geocoding';
import { MAPBOX_ACCESS_TOKEN } from '../env';
const geocoder = mbxGeocoding({ accessToken: MAPBOX_ACCESS_TOKEN });
const geocode = async (query: string) => {
const geoData = await geocoder.forwardGeocode({ query, limit: 1 }).send();
return geoData.body.features[0];
};
export default geocode;

View File

@@ -0,0 +1,28 @@
import multer from 'multer';
import { expressWrapper } from 'next-connect';
import { storage } from '../cloudinary';
const fileFilter: multer.Options['fileFilter'] = (req, file, callback) => {
const { mimetype } = file;
const isImage = mimetype.startsWith('image/');
if (!isImage) {
callback(null, false);
}
callback(null, true);
};
export const uploadMiddlewareMultiple = expressWrapper(
multer({ storage, fileFilter, limits: { files: 5, fileSize: 15 * 1024 * 1024 } }).array(
'images',
),
);
export const singleUploadMiddleware = expressWrapper(
multer({
storage,
fileFilter,
limits: { files: 1, fileSize: 15 * 1024 * 1024 },
}).single('image'),
);

View 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;

View File

@@ -0,0 +1,26 @@
import { NextApiResponse } from 'next';
import { NextHandler } from 'next-connect';
import ServerError from '@/config/util/ServerError';
import { findUserByIdService } from '@/services/users/auth';
import { getLoginSession } from '@/config/auth/session';
import { UserExtendedNextApiRequest } from '@/config/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 findUserByIdService({ userId: session?.id });
if (!user) {
throw new ServerError('User is not logged in.', 401);
}
req.user = user;
return next();
};
export default getCurrentUser;

View File

@@ -0,0 +1,47 @@
import ServerError from '@/config/util/ServerError';
import { NextApiRequest, NextApiResponse } from 'next';
import { NextHandler } from 'next-connect';
import { z } from 'zod';
interface ValidateRequestArgs {
bodySchema?: z.ZodSchema<any>;
querySchema?: z.ZodSchema<any>;
}
/**
* 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 }: ValidateRequestArgs) => {
return (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;

View File

@@ -0,0 +1,5 @@
import pino from 'pino';
const logger = pino();
export default logger;

View File

@@ -0,0 +1,35 @@
import { SPARKPOST_API_KEY, SPARKPOST_SENDER_ADDRESS } from '../env';
interface EmailParams {
address: string;
text: string;
html: string;
subject: string;
}
const sendEmail = async ({ address, text, html, subject }: EmailParams) => {
const from = SPARKPOST_SENDER_ADDRESS;
const data = {
recipients: [{ address }],
content: { from, subject, text, html },
};
const transmissionsEndpoint = 'https://api.sparkpost.com/api/v1/transmissions';
const response = await fetch(transmissionsEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: SPARKPOST_API_KEY,
},
body: JSON.stringify(data),
});
if (response.status !== 200) {
throw new Error(`Sparkpost API returned status code ${response.status}`);
}
};
export default sendEmail;

View File

@@ -0,0 +1,11 @@
class ServerError extends Error {
constructor(
message: string,
public statusCode: number,
) {
super(message);
this.name = 'ServerError';
}
}
export default ServerError;