diff options
| -rw-r--r-- | src/lib/auth.ts | 87 | ||||
| -rw-r--r-- | src/lib/minio.ts | 99 | ||||
| -rw-r--r-- | src/lib/mongodb.ts | 30 | ||||
| -rw-r--r-- | src/lib/utils.ts | 6 | ||||
| -rw-r--r-- | src/lib/validation.ts | 72 |
5 files changed, 294 insertions, 0 deletions
diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..7681009 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,87 @@ +import NextAuth, { type NextAuthOptions } from "next-auth" +import CredentialsProvider from "next-auth/providers/credentials" +import { MongoDBAdapter } from "@auth/mongodb-adapter" +import { MongoClient } from "mongodb" +import bcrypt from "bcryptjs" +import dbConnect from "./mongodb" +import User from "@/model/User" +import { loginSchema } from "./validation" + +const client = new MongoClient(process.env.MONGODB_URI!) + +export const authOptions: NextAuthOptions = { + adapter: MongoDBAdapter(client), + providers: [ + CredentialsProvider({ + name: "credentials", + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" } + }, + async authorize(credentials) { + if (!credentials?.email || !credentials?.password) { + return null + } + + // Validate and sanitize with Zod + const result = loginSchema.safeParse(credentials) + + if (!result.success) { + return null + } + + const { email, password } = result.data + + await dbConnect() + + const user = await User.findOne({ email }) + if (!user) { + return null + } + + const isPasswordValid = await bcrypt.compare(password, user.password) + if (!isPasswordValid) { + return null + } + + return { + id: user._id.toString(), + email: user.email, + name: user.name, + image: user.profileImage?.url || null, + } + } + }) + ], + session: { + strategy: "jwt" as const + }, + callbacks: { + async jwt({ token, user }: { token: any; user: any }) { + if (user) { + token.id = user.id + } + return token + }, + async session({ session, token }: { session: any; token: any }) { + if (token) { + session.user.id = token.id as string + + // Fetch latest user data from database to get current profile image + await dbConnect() + const currentUser = await User.findById(token.id) + if (currentUser) { + session.user.name = currentUser.name + session.user.email = currentUser.email + session.user.image = currentUser.profileImage?.url || null + } + } + return session + }, + }, + pages: { + signIn: "/login", + }, +} + +export default NextAuth(authOptions)
\ No newline at end of file diff --git a/src/lib/minio.ts b/src/lib/minio.ts new file mode 100644 index 0000000..04bf32d --- /dev/null +++ b/src/lib/minio.ts @@ -0,0 +1,99 @@ +import { Client } from 'minio' +import { Readable } from 'stream' + +const minioClient = new Client({ + endPoint: process.env.MINIO_ENDPOINT_HOST || 'localhost', + port: parseInt(process.env.MINIO_ENDPOINT_PORT || '9000', 10), + useSSL: false, //INFO: Set to true if using HTTPS + accessKey: process.env.MINIO_ACCESS_KEY || '', + secretKey: process.env.MINIO_SECRET_KEY || '', +}) + +const BUCKET_NAME = 'widget-storage' + +export async function ensureBucketExists() { + try { + const exists = await minioClient.bucketExists(BUCKET_NAME) + if (!exists) { + await minioClient.makeBucket(BUCKET_NAME, 'us-east-1') + console.log(`Created bucket: ${BUCKET_NAME}`) + + // Set bucket policy for public read access to profile images + const policy = { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: { AWS: ['*'] }, + Action: ['s3:GetObject'], + Resource: [`arn:aws:s3:::${BUCKET_NAME}/users/*/profile/*`] + } + ] + } + + await minioClient.setBucketPolicy(BUCKET_NAME, JSON.stringify(policy)) + console.log(`Set bucket policy for ${BUCKET_NAME}`) + } + } catch (error) { + console.error('Error ensuring bucket exists:', error) + throw error + } +} + +export async function uploadToMinio( + key: string, + buffer: Buffer, + contentType: string +): Promise<string> { + try { + await ensureBucketExists() + + const stream = Readable.from(buffer) + + await minioClient.putObject(BUCKET_NAME, key, stream, buffer.length, { + 'Content-Type': contentType, + 'Cache-Control': 'max-age=31536000', // 1 year cache + }) + + const url = `${process.env.MINIO_ENDPOINT ? `http://${process.env.MINIO_ENDPOINT}:9000` : 'http://localhost:9000'}/${BUCKET_NAME}/${key}` + + return url + } catch (error) { + console.error('Error uploading to MinIO:', error) + throw error + } +} + +export async function deleteFromMinio(key: string): Promise<void> { + try { + await minioClient.removeObject(BUCKET_NAME, key) + console.log(`Deleted file: ${key}`) + } catch (error) { + console.error('Error deleting from MinIO:', error) + throw error + } +} + +export async function getPresignedUploadUrl( + key: string, + expiresIn: number = 3600 +): Promise<string> { + try { + await ensureBucketExists() + return await minioClient.presignedPutObject(BUCKET_NAME, key, expiresIn) + } catch (error) { + console.error('Error generating presigned URL:', error) + throw error + } +} + +export async function getFileInfo(key: string) { + try { + return await minioClient.statObject(BUCKET_NAME, key) + } catch (error) { + console.error('Error getting file info:', error) + throw error + } +} + +export default minioClient
\ No newline at end of file diff --git a/src/lib/mongodb.ts b/src/lib/mongodb.ts new file mode 100644 index 0000000..1fc60d4 --- /dev/null +++ b/src/lib/mongodb.ts @@ -0,0 +1,30 @@ +import mongoose from "mongoose"; + +const MONGODB_URI = process.env.MONGODB_URI; + +if (!MONGODB_URI) { + throw new Error("Please define the MONGODB_URI environment variable inside .env.local"); +} + +interface Connection { + isConnected?: number; +} + +const connection: Connection = {}; + +async function dbConnect(): Promise<void> { + if (connection.isConnected) { + return; + } + + try { + const db = await mongoose.connect(MONGODB_URI!); + connection.isConnected = db.connections[0].readyState; + console.log("MongoDB connected successfully"); + } catch (error) { + console.error("MongoDB connection error:", error); + throw error; + } +} + +export default dbConnect;
\ No newline at end of file diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..bd0c391 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/src/lib/validation.ts b/src/lib/validation.ts new file mode 100644 index 0000000..02983e4 --- /dev/null +++ b/src/lib/validation.ts @@ -0,0 +1,72 @@ +import { z } from 'zod' + +export const registerSchema = z.object({ + name: z + .string() + .trim() + .min(2, 'Name must be at least 2 characters') + .max(50, 'Name must be at most 50 characters') + .regex(/^[a-zA-Z\s'-]+$/, 'Name can only contain letters, spaces, hyphens, and apostrophes'), + email: z + .string() + .trim() + .toLowerCase() + .email('Invalid email format') + .max(254, 'Email must be at most 254 characters'), + password: z + .string() + .min(8, 'Password must be at least 8 characters') + .max(128, 'Password must be at most 128 characters') + .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,}$/, + 'Password must contain at least one uppercase letter, one lowercase letter, and one number') +}) + +export const loginSchema = z.object({ + email: z + .string() + .trim() + .toLowerCase() + .email('Invalid email format') + .max(254, 'Email must be at most 254 characters'), + password: z + .string() + .max(128, 'Password must be at most 128 characters') +}) + +// Profile update schema (reusing name and email from registerSchema) +export const updateProfileSchema = z.object({ + name: registerSchema.shape.name, + email: registerSchema.shape.email, +}) + +// Password change schema (reusing password validation from registerSchema) +export const updatePasswordSchema = z.object({ + currentPassword: z.string().min(1, 'Current password is required'), + newPassword: registerSchema.shape.password, +}) + +// Type inference from schemas +export type RegisterInput = z.infer<typeof registerSchema> +export type LoginInput = z.infer<typeof loginSchema> +export type UpdateProfileInput = z.infer<typeof updateProfileSchema> +export type UpdatePasswordInput = z.infer<typeof updatePasswordSchema> + +export const emailSchema = z.object({ + email: z + .string() + .trim() + .toLowerCase() + .email('Invalid email format') + .max(254, 'Email must be at most 254 characters') +}) + +export function formatZodError(error: z.ZodError) { + return error.errors.map(err => ({ + field: err.path.join('.'), + message: err.message + })) +} + +export function getFirstErrorMessage(error: z.ZodError): string { + return error.errors[0]?.message || 'Validation failed' +}
\ No newline at end of file |
