aboutsummaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
authorschererleander <leander@schererleander.de>2025-07-02 22:17:57 +0200
committerschererleander <leander@schererleander.de>2025-07-02 22:17:57 +0200
commitab03900adf080da08a0b2a3628fd0dcf0af28420 (patch)
treea54ddb49b949929c5c6b4b0bd2b7ca5c504da54d /src/lib
parent198f95f079078e60e05d1ea6607ee14f79721a7e (diff)
feat: add libraries and utilities
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/auth.ts87
-rw-r--r--src/lib/minio.ts99
-rw-r--r--src/lib/mongodb.ts30
-rw-r--r--src/lib/utils.ts6
-rw-r--r--src/lib/validation.ts72
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