Initial commit
This commit is contained in:
71
lib/app-setup.ts
Normal file
71
lib/app-setup.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { getPrisma } from "./db";
|
||||
|
||||
export type AppSetupData = {
|
||||
googleAuthEnabled: boolean;
|
||||
googleClientId?: string | null;
|
||||
googleClientSecret?: string | null;
|
||||
socials?: {
|
||||
twitter?: string;
|
||||
instagram?: string;
|
||||
linkedin?: string;
|
||||
youtube?: string;
|
||||
[key: string]: string | undefined;
|
||||
};
|
||||
categories?: string[];
|
||||
};
|
||||
|
||||
const defaultSetup: AppSetupData = {
|
||||
googleAuthEnabled: false,
|
||||
googleClientId: null,
|
||||
googleClientSecret: null,
|
||||
socials: {},
|
||||
categories: ["Basics", "Planning", "Tax", "Healthcare", "Advanced"],
|
||||
};
|
||||
|
||||
export async function getAppSetup(): Promise<AppSetupData> {
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) return defaultSetup;
|
||||
|
||||
const row = await prisma.appSetup.findUnique({ where: { id: 1 } });
|
||||
if (!row) return defaultSetup;
|
||||
|
||||
return {
|
||||
googleAuthEnabled: row.googleAuthEnabled,
|
||||
googleClientId: row.googleClientId,
|
||||
googleClientSecret: row.googleClientSecret,
|
||||
socials: (row.socials || {}) as any,
|
||||
categories: (row.categories || []) as any,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveAppSetup(data: AppSetupData) {
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) return defaultSetup;
|
||||
|
||||
const saved = await prisma.appSetup.upsert({
|
||||
where: { id: 1 },
|
||||
update: {
|
||||
googleAuthEnabled: data.googleAuthEnabled,
|
||||
googleClientId: data.googleClientId || null,
|
||||
googleClientSecret: data.googleClientSecret || null,
|
||||
socials: (data.socials || {}) as any,
|
||||
categories: (data.categories || []) as any,
|
||||
},
|
||||
create: {
|
||||
id: 1,
|
||||
googleAuthEnabled: data.googleAuthEnabled,
|
||||
googleClientId: data.googleClientId || null,
|
||||
googleClientSecret: data.googleClientSecret || null,
|
||||
socials: (data.socials || {}) as any,
|
||||
categories: (data.categories || []) as any,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
googleAuthEnabled: saved.googleAuthEnabled,
|
||||
googleClientId: saved.googleClientId,
|
||||
googleClientSecret: saved.googleClientSecret,
|
||||
socials: (saved.socials || {}) as any,
|
||||
categories: (saved.categories || []) as any,
|
||||
} as AppSetupData;
|
||||
}
|
||||
9
lib/auth-client.ts
Normal file
9
lib/auth-client.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
const baseURL = typeof window !== "undefined" ? window.location.origin : "http://localhost:3001";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: baseURL,
|
||||
});
|
||||
|
||||
export const { signUp, signIn, signOut, useSession } = authClient;
|
||||
128
lib/auth.ts
Normal file
128
lib/auth.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||
import { getPrisma } from "./db";
|
||||
import { loadSystemConfig } from "./system-config";
|
||||
import { setCached, getCached, deleteCached, cacheKeys } from "./redis";
|
||||
|
||||
let authInstance: ReturnType<typeof betterAuth> | null = null;
|
||||
|
||||
export async function getAuth() {
|
||||
if (authInstance) {
|
||||
return authInstance;
|
||||
}
|
||||
|
||||
const prisma = await getPrisma();
|
||||
const cfg = await loadSystemConfig();
|
||||
|
||||
if (!prisma) {
|
||||
throw new Error("Database not configured");
|
||||
}
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV === "development";
|
||||
const baseURL = process.env.APP_BASE_URL ||
|
||||
(isDevelopment ? "http://localhost:3001" : "https://estate-platform.com");
|
||||
|
||||
authInstance = betterAuth({
|
||||
database: prismaAdapter(prisma, {
|
||||
provider: "postgresql",
|
||||
}),
|
||||
secret: cfg.auth?.jwtSecret || process.env.BETTER_AUTH_SECRET || "default-secret-change-me",
|
||||
baseURL: baseURL,
|
||||
trustHost: true,
|
||||
// Disable origin validation in development
|
||||
...(isDevelopment && {
|
||||
skipOriginValidation: true,
|
||||
}),
|
||||
csrfProtection: false, // Disable CSRF protection completely
|
||||
advanced: {
|
||||
useSecureCookies: !isDevelopment, // Allow non-secure cookies in development
|
||||
disableMultipleTabsWarning: isDevelopment,
|
||||
sessionTimeout: 86400 * 7, // 7 days
|
||||
defaultCookieMaxAge: 86400 * 7, // 7 days
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
autoSignUpDisabled: false,
|
||||
minPasswordLength: 8,
|
||||
maxPasswordLength: 20,
|
||||
requireEmailVerification: cfg.email?.enabled || false,
|
||||
},
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: cfg.oauth?.google?.clientId || process.env.GOOGLE_CLIENT_ID || "",
|
||||
clientSecret: cfg.oauth?.google?.clientSecret || process.env.GOOGLE_CLIENT_SECRET || "",
|
||||
},
|
||||
github: {
|
||||
clientId: cfg.oauth?.github?.clientId || process.env.GITHUB_CLIENT_ID || "",
|
||||
clientSecret: cfg.oauth?.github?.clientSecret || process.env.GITHUB_CLIENT_SECRET || "",
|
||||
},
|
||||
facebook: {
|
||||
clientId: cfg.oauth?.facebook?.clientId || process.env.FACEBOOK_CLIENT_ID || "",
|
||||
clientSecret: cfg.oauth?.facebook?.clientSecret || process.env.FACEBOOK_CLIENT_SECRET || "",
|
||||
},
|
||||
discord: {
|
||||
clientId: cfg.oauth?.discord?.clientId || process.env.DISCORD_CLIENT_ID || "",
|
||||
clientSecret: cfg.oauth?.discord?.clientSecret || process.env.DISCORD_CLIENT_SECRET || "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return authInstance;
|
||||
}
|
||||
|
||||
// Session caching helper
|
||||
export async function cacheSession(sessionId: string, sessionData: any) {
|
||||
try {
|
||||
await setCached(cacheKeys.session(sessionId), sessionData, 604800); // 7 days
|
||||
} catch (error) {
|
||||
console.error("[Auth] Error caching session:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCachedSession(sessionId: string) {
|
||||
try {
|
||||
return await getCached(cacheKeys.session(sessionId));
|
||||
} catch (error) {
|
||||
console.error("[Auth] Error getting cached session:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function invalidateSessionCache(sessionId: string) {
|
||||
try {
|
||||
await deleteCached(cacheKeys.session(sessionId));
|
||||
} catch (error) {
|
||||
console.error("[Auth] Error invalidating session cache:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// User caching helper
|
||||
export async function cacheUser(userId: string, userData: any) {
|
||||
try {
|
||||
await setCached(cacheKeys.user(userId), userData, 3600); // 1 hour
|
||||
} catch (error) {
|
||||
console.error("[Auth] Error caching user:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCachedUser(userId: string) {
|
||||
try {
|
||||
return await getCached(cacheKeys.user(userId));
|
||||
} catch (error) {
|
||||
console.error("[Auth] Error getting cached user:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function invalidateUserCache(userId: string) {
|
||||
try {
|
||||
await deleteCached(cacheKeys.user(userId));
|
||||
} catch (error) {
|
||||
console.error("[Auth] Error invalidating user cache:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export BetterAuth session types
|
||||
export type { Session } from "better-auth";
|
||||
export type { User } from "better-auth";
|
||||
|
||||
38
lib/auth/jwt.ts
Normal file
38
lib/auth/jwt.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import { loadSystemConfig } from "../system-config";
|
||||
|
||||
export type SessionToken = {
|
||||
sub: string; // userId
|
||||
role: "ADMIN" | "USER";
|
||||
email: string;
|
||||
forcePasswordReset: boolean;
|
||||
};
|
||||
|
||||
async function getSecret() {
|
||||
const cfg = await loadSystemConfig();
|
||||
const s = cfg.auth?.jwtSecret || process.env.JWT_SECRET;
|
||||
if (!s) return null;
|
||||
return new TextEncoder().encode(s);
|
||||
}
|
||||
|
||||
export async function signSession(payload: SessionToken) {
|
||||
const secret = await getSecret();
|
||||
if (!secret) throw new Error("JWT secret not configured");
|
||||
const token = await new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("7d")
|
||||
.sign(secret);
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function verifySession(token: string) {
|
||||
const secret = await getSecret();
|
||||
if (!secret) return null;
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, secret);
|
||||
return payload as any as SessionToken;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
9
lib/auth/password.ts
Normal file
9
lib/auth/password.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
export async function hashPassword(pw: string) {
|
||||
return bcrypt.hash(pw, 10);
|
||||
}
|
||||
|
||||
export async function verifyPassword(pw: string, hash: string) {
|
||||
return bcrypt.compare(pw, hash);
|
||||
}
|
||||
94
lib/auth/rate-limit.ts
Normal file
94
lib/auth/rate-limit.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Simple in-memory rate limiter for login attempts
|
||||
* Tracks login attempts per email address
|
||||
*/
|
||||
|
||||
interface RateLimitEntry {
|
||||
attempts: number;
|
||||
resetTime: number;
|
||||
}
|
||||
|
||||
const loginAttempts = new Map<string, RateLimitEntry>();
|
||||
const ATTEMPT_LIMIT = 5;
|
||||
const TIME_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
export function checkRateLimit(email: string): {
|
||||
allowed: boolean;
|
||||
attempts: number;
|
||||
remainingTime: number;
|
||||
} {
|
||||
const now = Date.now();
|
||||
const entry = loginAttempts.get(email);
|
||||
|
||||
// No previous attempts
|
||||
if (!entry) {
|
||||
loginAttempts.set(email, {
|
||||
attempts: 1,
|
||||
resetTime: now + TIME_WINDOW_MS,
|
||||
});
|
||||
return {
|
||||
allowed: true,
|
||||
attempts: 1,
|
||||
remainingTime: TIME_WINDOW_MS,
|
||||
};
|
||||
}
|
||||
|
||||
// Window has expired
|
||||
if (now > entry.resetTime) {
|
||||
loginAttempts.set(email, {
|
||||
attempts: 1,
|
||||
resetTime: now + TIME_WINDOW_MS,
|
||||
});
|
||||
return {
|
||||
allowed: true,
|
||||
attempts: 1,
|
||||
remainingTime: TIME_WINDOW_MS,
|
||||
};
|
||||
}
|
||||
|
||||
// Still within the window
|
||||
const remainingTime = entry.resetTime - now;
|
||||
const allowed = entry.attempts < ATTEMPT_LIMIT;
|
||||
|
||||
if (!allowed) {
|
||||
// Still increment attempts to track that we rejected this attempt
|
||||
return {
|
||||
allowed: false,
|
||||
attempts: entry.attempts,
|
||||
remainingTime,
|
||||
};
|
||||
}
|
||||
|
||||
// Increment attempts
|
||||
entry.attempts++;
|
||||
return {
|
||||
allowed: true,
|
||||
attempts: entry.attempts,
|
||||
remainingTime,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failed login attempt (call this on invalid credentials)
|
||||
* This increments the counter without checking the limit
|
||||
*/
|
||||
export function recordFailedAttempt(email: string): void {
|
||||
const now = Date.now();
|
||||
const entry = loginAttempts.get(email);
|
||||
|
||||
if (!entry || now > entry.resetTime) {
|
||||
loginAttempts.set(email, {
|
||||
attempts: 1,
|
||||
resetTime: now + TIME_WINDOW_MS,
|
||||
});
|
||||
} else {
|
||||
entry.attempts++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all rate limit entries (useful for testing or admin reset)
|
||||
*/
|
||||
export function clearRateLimits(): void {
|
||||
loginAttempts.clear();
|
||||
}
|
||||
11
lib/auth/session.ts
Normal file
11
lib/auth/session.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { verifySession } from "./jwt";
|
||||
|
||||
export const SESSION_COOKIE = "ep_session";
|
||||
|
||||
export async function getSession() {
|
||||
const cookieStore = await cookies();
|
||||
const c = cookieStore.get(SESSION_COOKIE)?.value;
|
||||
if (!c) return null;
|
||||
return await verifySession(c);
|
||||
}
|
||||
45
lib/auth/validation.ts
Normal file
45
lib/auth/validation.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export function sanitizeText(value: string) {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function sanitizeMessage(value: string) {
|
||||
// Allow only alphanumeric, spaces, underscores, newlines, and basic punctuation
|
||||
// Remove any HTML tags, scripts, and dangerous characters
|
||||
return value
|
||||
.trim()
|
||||
.replace(/<[^>]*>/g, '') // Remove HTML tags
|
||||
.replace(/[^\w\s\n\r_.,!?'-]/g, '') // Keep only safe characters
|
||||
.slice(0, 5000); // Limit length
|
||||
}
|
||||
|
||||
export interface PasswordRequirement {
|
||||
name: string;
|
||||
met: boolean;
|
||||
}
|
||||
|
||||
export function getPasswordRequirements(value: string): PasswordRequirement[] {
|
||||
return [
|
||||
{
|
||||
name: "8-20 Characters",
|
||||
met: value.length >= 8 && value.length <= 20,
|
||||
},
|
||||
{
|
||||
name: "At least one capital letter",
|
||||
met: /[A-Z]/.test(value),
|
||||
},
|
||||
{
|
||||
name: "At least one number",
|
||||
met: /\d/.test(value),
|
||||
},
|
||||
{
|
||||
name: "No spaces",
|
||||
met: !/\s/.test(value),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function isStrongPassword(value: string) {
|
||||
// 8-20 chars, at least one uppercase, at least one number, no spaces
|
||||
return /^(?=.*[A-Z])(?=.*\d)[A-Za-z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]{8,20}$/.test(value) && !/\s/.test(value);
|
||||
}
|
||||
|
||||
175
lib/calendar.ts
Normal file
175
lib/calendar.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { loadSystemConfig } from "./system-config";
|
||||
|
||||
export type CalendarEvent = {
|
||||
summary: string;
|
||||
description: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
location?: string;
|
||||
attendees?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate ICS file content for calendar invitation
|
||||
*/
|
||||
export function generateIcsFile(event: CalendarEvent): string {
|
||||
const formatDate = (date: Date): string => {
|
||||
return date
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, "")
|
||||
.replace(/\.\d{3}/, "");
|
||||
};
|
||||
|
||||
const startDateStr = formatDate(event.startTime);
|
||||
const endDateStr = formatDate(event.endTime);
|
||||
const now = formatDate(new Date());
|
||||
const uid = `${now}@estate-platform.com`;
|
||||
|
||||
// Escape special characters in text
|
||||
const escape = (text: string) => {
|
||||
return text
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/;/g, "\\;")
|
||||
.replace(/,/g, "\\,")
|
||||
.replace(/\n/g, "\\n");
|
||||
};
|
||||
|
||||
let icsContent = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//Estate Platform//Webinar Calendar//EN",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"METHOD:REQUEST",
|
||||
"BEGIN:VEVENT",
|
||||
`UID:${uid}`,
|
||||
`DTSTAMP:${now}`,
|
||||
`DTSTART:${startDateStr}`,
|
||||
`DTEND:${endDateStr}`,
|
||||
`SUMMARY:${escape(event.summary)}`,
|
||||
`DESCRIPTION:${escape(event.description)}`,
|
||||
];
|
||||
|
||||
if (event.location) {
|
||||
icsContent.push(`LOCATION:${escape(event.location)}`);
|
||||
}
|
||||
|
||||
// Add organizer
|
||||
icsContent.push("ORGANIZER;CN=Estate Platform:mailto:noreply@estate-platform.com");
|
||||
|
||||
// Add attendees
|
||||
if (event.attendees && event.attendees.length > 0) {
|
||||
event.attendees.forEach((email) => {
|
||||
icsContent.push(
|
||||
`ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:${email}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
icsContent.push(
|
||||
"STATUS:CONFIRMED",
|
||||
"SEQUENCE:0",
|
||||
"BEGIN:VALARM",
|
||||
"TRIGGER:-PT15M",
|
||||
"ACTION:DISPLAY",
|
||||
"DESCRIPTION:Reminder: Webinar starts in 15 minutes",
|
||||
"END:VALARM",
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
);
|
||||
|
||||
return icsContent.join("\r\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Google Calendar event (if service account is configured)
|
||||
*/
|
||||
export async function createGoogleCalendarEvent(
|
||||
event: CalendarEvent
|
||||
): Promise<{ success: boolean; eventId?: string; error?: string }> {
|
||||
try {
|
||||
const config = await loadSystemConfig();
|
||||
const calendarConfig = config.googleCalendar;
|
||||
|
||||
if (!calendarConfig?.enabled) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Google Calendar is not enabled",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
!calendarConfig.serviceAccountEmail ||
|
||||
!calendarConfig.serviceAccountKey ||
|
||||
!calendarConfig.calendarId
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Google Calendar credentials are not configured",
|
||||
};
|
||||
}
|
||||
|
||||
// In a real implementation, you would use the googleapis library
|
||||
// to create the event using service account credentials
|
||||
// For now, we'll just return a mock success
|
||||
// TODO: Implement actual Google Calendar API integration
|
||||
|
||||
console.log("[CALENDAR] Would create event:", event);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
eventId: `mock-event-${Date.now()}`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[CALENDAR] Error creating event:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Failed to create calendar event",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to calculate end time based on duration in minutes
|
||||
*/
|
||||
export function calculateEndTime(startTime: Date, durationMinutes: number): Date {
|
||||
return new Date(startTime.getTime() + durationMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a calendar event for a webinar registration
|
||||
*/
|
||||
export async function createWebinarCalendarEvent(
|
||||
webinar: {
|
||||
title: string;
|
||||
description: string;
|
||||
startAt: Date;
|
||||
duration: number;
|
||||
meetingInfo: any;
|
||||
},
|
||||
attendeeEmail: string
|
||||
): Promise<{ icsContent: string; googleEventId?: string }> {
|
||||
const endTime = calculateEndTime(webinar.startAt, webinar.duration);
|
||||
|
||||
const meetingLink = webinar.meetingInfo?.meetingLink || "TBD";
|
||||
const description = `${webinar.description}\n\nJoin Link: ${meetingLink}`;
|
||||
|
||||
const event: CalendarEvent = {
|
||||
summary: webinar.title,
|
||||
description,
|
||||
startTime: webinar.startAt,
|
||||
endTime,
|
||||
location: meetingLink,
|
||||
attendees: [attendeeEmail],
|
||||
};
|
||||
|
||||
// Generate ICS file
|
||||
const icsContent = generateIcsFile(event);
|
||||
|
||||
// Optionally create Google Calendar event
|
||||
const googleResult = await createGoogleCalendarEvent(event);
|
||||
|
||||
return {
|
||||
icsContent,
|
||||
googleEventId: googleResult.success ? googleResult.eventId : undefined,
|
||||
};
|
||||
}
|
||||
76
lib/captcha.ts
Normal file
76
lib/captcha.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
// Simple in-memory CAPTCHA store (in production, use Redis or database)
|
||||
const captchaStore: Map<string, { code: string; createdAt: number; attempts: number }> = new Map();
|
||||
|
||||
const CAPTCHA_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const CLEANUP_INTERVAL = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
// Cleanup old captchas periodically
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of captchaStore.entries()) {
|
||||
if (now - value.createdAt > CAPTCHA_TTL) {
|
||||
captchaStore.delete(key);
|
||||
}
|
||||
}
|
||||
}, CLEANUP_INTERVAL);
|
||||
|
||||
export function generateCaptcha(identifier: string): { id: string; code: string } {
|
||||
const code = Math.random().toString().slice(2, 8); // 6-digit code
|
||||
const id = crypto.randomBytes(16).toString("hex");
|
||||
|
||||
captchaStore.set(id, {
|
||||
code,
|
||||
createdAt: Date.now(),
|
||||
attempts: 0,
|
||||
});
|
||||
|
||||
return { id, code };
|
||||
}
|
||||
|
||||
export interface CaptchaVerificationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
attemptsRemaining?: number;
|
||||
}
|
||||
|
||||
export function verifyCaptcha(id: string, code: string): CaptchaVerificationResult {
|
||||
const captcha = captchaStore.get(id);
|
||||
|
||||
if (!captcha) {
|
||||
return { success: false, error: "CAPTCHA not found or expired. Please reload and try again." };
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (Date.now() - captcha.createdAt > CAPTCHA_TTL) {
|
||||
captchaStore.delete(id);
|
||||
return { success: false, error: "CAPTCHA code has expired. Please request a new one." };
|
||||
}
|
||||
|
||||
// Check attempts
|
||||
if (captcha.attempts >= MAX_ATTEMPTS) {
|
||||
captchaStore.delete(id);
|
||||
return { success: false, error: "Too many incorrect attempts. Please request a new CAPTCHA code." };
|
||||
}
|
||||
|
||||
// Verify code
|
||||
if (captcha.code === code) {
|
||||
captchaStore.delete(id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Increment attempts
|
||||
captcha.attempts++;
|
||||
const remaining = MAX_ATTEMPTS - captcha.attempts;
|
||||
return {
|
||||
success: false,
|
||||
error: `Incorrect CAPTCHA code. ${remaining} attempt${remaining === 1 ? "" : "s"} remaining.`,
|
||||
attemptsRemaining: remaining,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMaxAttempts(): number {
|
||||
return MAX_ATTEMPTS;
|
||||
}
|
||||
26
lib/db.ts
Normal file
26
lib/db.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { loadSystemConfig } from "./system-config";
|
||||
|
||||
let prisma: PrismaClient | null = null;
|
||||
|
||||
export async function getPrisma(): Promise<PrismaClient | null> {
|
||||
if (prisma) return prisma;
|
||||
|
||||
const cfg = await loadSystemConfig();
|
||||
const url = cfg.db?.databaseUrl || process.env.DATABASE_URL;
|
||||
|
||||
if (!url) return null;
|
||||
|
||||
// Set env so Prisma picks it up at runtime
|
||||
process.env.DATABASE_URL = url;
|
||||
|
||||
try {
|
||||
prisma = new PrismaClient();
|
||||
// quick connectivity check (non-fatal if it fails)
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
return prisma;
|
||||
} catch {
|
||||
prisma = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
39
lib/email.ts
Normal file
39
lib/email.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { loadSystemConfig } from "./system-config";
|
||||
|
||||
export type EmailAttachment = {
|
||||
filename: string;
|
||||
content: string;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
export async function sendEmail(opts: {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
attachments?: EmailAttachment[];
|
||||
}) {
|
||||
const cfg = await loadSystemConfig();
|
||||
if (!cfg.email?.enabled || !cfg.email?.smtp?.host || !cfg.email?.smtp?.port || !cfg.email?.from) {
|
||||
return { ok: false, message: "Email not configured" };
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: cfg.email.smtp.host,
|
||||
port: cfg.email.smtp.port,
|
||||
secure: false,
|
||||
auth: cfg.email.smtp.user
|
||||
? { user: cfg.email.smtp.user, pass: cfg.email.smtp.pass }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: cfg.email.from,
|
||||
to: opts.to,
|
||||
subject: opts.subject,
|
||||
html: opts.html,
|
||||
attachments: opts.attachments,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
20
lib/errors.ts
Normal file
20
lib/errors.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export class PublicError extends Error {
|
||||
code: string;
|
||||
constructor(code: string, message?: string) {
|
||||
super(message ?? code);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function publicMessageFor(code: string) {
|
||||
// Consumer-safe message - only for sensitive pages like auth
|
||||
return "Something went wrong. Please contact the website owner.";
|
||||
}
|
||||
|
||||
export function createValidationError(message: string) {
|
||||
return new PublicError("VALIDATION_ERROR", message);
|
||||
}
|
||||
|
||||
export function isPublicError(err: unknown): err is PublicError {
|
||||
return typeof err === "object" && err !== null && "code" in err && err instanceof Error;
|
||||
}
|
||||
23
lib/http.ts
Normal file
23
lib/http.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PublicError, publicMessageFor } from "./errors";
|
||||
|
||||
export function ok(data: any = {}) {
|
||||
return NextResponse.json({ ok: true, ...data });
|
||||
}
|
||||
|
||||
export function fail(err: unknown, opts?: { adminMessage?: string; status?: number; isAdmin?: boolean }) {
|
||||
const status = opts?.status ?? 400;
|
||||
|
||||
if (opts?.isAdmin) {
|
||||
const msg =
|
||||
err instanceof PublicError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Unknown error";
|
||||
return NextResponse.json({ ok: false, message: msg }, { status });
|
||||
}
|
||||
|
||||
// consumer-safe
|
||||
return NextResponse.json({ ok: false, message: publicMessageFor("GENERIC") }, { status });
|
||||
}
|
||||
142
lib/redis.ts
Normal file
142
lib/redis.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import Redis from "ioredis";
|
||||
|
||||
let redisInstance: Redis | null = null;
|
||||
|
||||
export async function getRedis(): Promise<Redis> {
|
||||
if (redisInstance) {
|
||||
return redisInstance;
|
||||
}
|
||||
|
||||
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379";
|
||||
|
||||
try {
|
||||
redisInstance = new Redis(redisUrl, {
|
||||
lazyConnect: false,
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: false,
|
||||
enableOfflineQueue: true,
|
||||
retryStrategy: (times) => {
|
||||
const delay = Math.min(times * 50, 2000);
|
||||
return delay;
|
||||
},
|
||||
});
|
||||
|
||||
redisInstance.on("error", (err) => {
|
||||
console.error("[Redis] Error:", err.message);
|
||||
});
|
||||
|
||||
redisInstance.on("connect", () => {
|
||||
console.log("[Redis] Connected");
|
||||
});
|
||||
|
||||
redisInstance.on("ready", () => {
|
||||
console.log("[Redis] Ready");
|
||||
});
|
||||
|
||||
redisInstance.on("reconnecting", () => {
|
||||
console.log("[Redis] Reconnecting...");
|
||||
});
|
||||
|
||||
// Test connection
|
||||
await redisInstance.ping();
|
||||
console.log("[Redis] Ping successful");
|
||||
|
||||
return redisInstance;
|
||||
} catch (error) {
|
||||
console.error("[Redis] Connection failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeRedis(): Promise<void> {
|
||||
if (redisInstance) {
|
||||
await redisInstance.quit();
|
||||
redisInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache helper functions
|
||||
export async function getCached<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
const redis = await getRedis();
|
||||
const data = await redis.get(key);
|
||||
return data ? JSON.parse(data) : null;
|
||||
} catch (error) {
|
||||
console.error(`[Redis] Error getting cache for ${key}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setCached<T>(
|
||||
key: string,
|
||||
value: T,
|
||||
expirationSeconds?: number
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const redis = await getRedis();
|
||||
const data = JSON.stringify(value);
|
||||
|
||||
if (expirationSeconds) {
|
||||
await redis.setex(key, expirationSeconds, data);
|
||||
} else {
|
||||
await redis.set(key, data);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[Redis] Error setting cache for ${key}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCached(key: string): Promise<boolean> {
|
||||
try {
|
||||
const redis = await getRedis();
|
||||
const result = await redis.del(key);
|
||||
return result > 0;
|
||||
} catch (error) {
|
||||
console.error(`[Redis] Error deleting cache for ${key}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function invalidateCachePattern(pattern: string): Promise<number> {
|
||||
try {
|
||||
const redis = await getRedis();
|
||||
const keys = await redis.keys(pattern);
|
||||
if (keys.length === 0) return 0;
|
||||
return await redis.del(...keys);
|
||||
} catch (error) {
|
||||
console.error(`[Redis] Error invalidating cache pattern ${pattern}:`, error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Session helpers for BetterAuth
|
||||
export async function getSession(sessionId: string): Promise<any> {
|
||||
return getCached(`session:${sessionId}`);
|
||||
}
|
||||
|
||||
export async function setSession(
|
||||
sessionId: string,
|
||||
sessionData: any,
|
||||
expirationSeconds: number = 86400 // 24 hours
|
||||
): Promise<boolean> {
|
||||
return setCached(`session:${sessionId}`, sessionData, expirationSeconds);
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string): Promise<boolean> {
|
||||
return deleteCached(`session:${sessionId}`);
|
||||
}
|
||||
|
||||
// Cache key generators
|
||||
export const cacheKeys = {
|
||||
user: (userId: string) => `user:${userId}`,
|
||||
userByEmail: (email: string) => `user:email:${email}`,
|
||||
session: (sessionId: string) => `session:${sessionId}`,
|
||||
webinar: (webinarId: string) => `webinar:${webinarId}`,
|
||||
webinars: (page: number = 1) => `webinars:list:${page}`,
|
||||
registrations: (userId: string) => `registrations:${userId}`,
|
||||
contact: (contactId: string) => `contact:${contactId}`,
|
||||
config: "system:config",
|
||||
adminSetup: "admin:setup",
|
||||
};
|
||||
13
lib/stripe.ts
Normal file
13
lib/stripe.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import Stripe from "stripe";
|
||||
import { loadSystemConfig } from "./system-config";
|
||||
|
||||
let stripe: Stripe | null = null;
|
||||
|
||||
export async function getStripe() {
|
||||
if (stripe) return stripe;
|
||||
const cfg = await loadSystemConfig();
|
||||
const key = cfg.stripe?.secretKey || process.env.STRIPE_SECRET_KEY;
|
||||
if (!cfg.stripe?.enabled || !key) return null;
|
||||
stripe = new Stripe(key, { apiVersion: "2024-06-20" as any });
|
||||
return stripe;
|
||||
}
|
||||
147
lib/system-config.ts
Normal file
147
lib/system-config.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
export type SystemConfigData = {
|
||||
app?: { initialized?: boolean };
|
||||
db?: { databaseUrl?: string };
|
||||
auth?: { jwtSecret?: string };
|
||||
email?: {
|
||||
enabled?: boolean;
|
||||
provider?: "smtp";
|
||||
fromAddress?: string;
|
||||
from?: string;
|
||||
smtp?: { host?: string; port?: number; user?: string; pass?: string };
|
||||
};
|
||||
stripe?: {
|
||||
enabled?: boolean;
|
||||
secretKey?: string;
|
||||
webhookSecret?: string;
|
||||
publishableKey?: string;
|
||||
};
|
||||
oauth?: {
|
||||
google?: { enabled?: boolean; clientId?: string; clientSecret?: string };
|
||||
github?: { enabled?: boolean; clientId?: string; clientSecret?: string };
|
||||
facebook?: { enabled?: boolean; clientId?: string; clientSecret?: string };
|
||||
discord?: { enabled?: boolean; clientId?: string; clientSecret?: string };
|
||||
};
|
||||
googleAuth?: {
|
||||
enabled?: boolean;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
};
|
||||
googleCalendar?: {
|
||||
enabled?: boolean;
|
||||
serviceAccountEmail?: string;
|
||||
serviceAccountKey?: string;
|
||||
calendarId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const DATA_DIR = process.env.CONFIG_DIR || path.join(process.cwd(), "data");
|
||||
const FILE_PATH = path.join(DATA_DIR, "system-config.json");
|
||||
|
||||
const defaultConfig: SystemConfigData = {
|
||||
app: { initialized: false },
|
||||
db: { databaseUrl: process.env.DATABASE_URL },
|
||||
auth: { jwtSecret: process.env.JWT_SECRET || process.env.BETTER_AUTH_SECRET },
|
||||
email: {
|
||||
enabled: Boolean(process.env.EMAIL_FROM),
|
||||
provider: (process.env.EMAIL_PROVIDER as any) || undefined,
|
||||
fromAddress: process.env.EMAIL_FROM,
|
||||
from: process.env.EMAIL_FROM,
|
||||
smtp: {
|
||||
host: process.env.SMTP_HOST,
|
||||
port: process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : undefined,
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
},
|
||||
stripe: {
|
||||
enabled: Boolean(process.env.STRIPE_SECRET_KEY),
|
||||
secretKey: process.env.STRIPE_SECRET_KEY,
|
||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
|
||||
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY,
|
||||
},
|
||||
oauth: {
|
||||
google: {
|
||||
enabled: Boolean(process.env.GOOGLE_CLIENT_ID),
|
||||
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
},
|
||||
github: {
|
||||
enabled: Boolean(process.env.GITHUB_CLIENT_ID),
|
||||
clientId: process.env.GITHUB_CLIENT_ID,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
||||
},
|
||||
facebook: {
|
||||
enabled: Boolean(process.env.FACEBOOK_CLIENT_ID),
|
||||
clientId: process.env.FACEBOOK_CLIENT_ID,
|
||||
clientSecret: process.env.FACEBOOK_CLIENT_SECRET,
|
||||
},
|
||||
discord: {
|
||||
enabled: Boolean(process.env.DISCORD_CLIENT_ID),
|
||||
clientId: process.env.DISCORD_CLIENT_ID,
|
||||
clientSecret: process.env.DISCORD_CLIENT_SECRET,
|
||||
},
|
||||
},
|
||||
googleAuth: {
|
||||
enabled: Boolean(process.env.GOOGLE_CLIENT_ID),
|
||||
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
},
|
||||
};
|
||||
|
||||
async function ensureDir() {
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
export async function loadSystemConfig(): Promise<SystemConfigData> {
|
||||
await ensureDir();
|
||||
try {
|
||||
const raw = await fs.readFile(FILE_PATH, "utf-8");
|
||||
const fileCfg = JSON.parse(raw) as SystemConfigData;
|
||||
return deepMerge(defaultConfig, fileCfg);
|
||||
} catch {
|
||||
// create initial file config
|
||||
await fs.writeFile(FILE_PATH, JSON.stringify(defaultConfig, null, 2), "utf-8");
|
||||
return defaultConfig;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSystemConfig(partial: SystemConfigData, prisma?: PrismaClient | null) {
|
||||
const current = await loadSystemConfig();
|
||||
const merged = deepMerge(current, partial);
|
||||
await ensureDir();
|
||||
await fs.writeFile(FILE_PATH, JSON.stringify(merged, null, 2), "utf-8");
|
||||
|
||||
// Also persist to DB if available (best-effort)
|
||||
if (prisma) {
|
||||
try {
|
||||
await prisma.systemConfig.upsert({
|
||||
where: { id: 1 },
|
||||
update: { data: merged as any },
|
||||
create: { id: 1, data: merged as any },
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function isObject(v: any) {
|
||||
return v && typeof v === "object" && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function deepMerge<T>(base: T, patch: any): T {
|
||||
if (!isObject(base) || !isObject(patch)) return (patch ?? base) as T;
|
||||
const out: any = { ...base };
|
||||
for (const k of Object.keys(patch)) {
|
||||
const bv = (base as any)[k];
|
||||
const pv = patch[k];
|
||||
out[k] = isObject(bv) && isObject(pv) ? deepMerge(bv, pv) : pv;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user