import Database from 'better-sqlite3' import { join } from 'path' import bcrypt from 'bcrypt' import crypto from 'crypto' let db: Database.Database | null = null /** * Generate a cryptographically secure random string * @param length Length of the string to generate * @param charset Character set to use (default: alphanumeric + symbols) */ function generateSecurePassword(length: number = 16): string { const lowercase = 'abcdefghijklmnopqrstuvwxyz' const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' const numbers = '0123456789' const symbols = '!@#$%^&*-_=+' const allChars = lowercase + uppercase + numbers + symbols let password = '' // Ensure at least one character from each category password += lowercase[crypto.randomInt(lowercase.length)] password += uppercase[crypto.randomInt(uppercase.length)] password += numbers[crypto.randomInt(numbers.length)] password += symbols[crypto.randomInt(symbols.length)] // Fill the rest randomly for (let i = password.length; i < length; i++) { password += allChars[crypto.randomInt(allChars.length)] } // Shuffle the password to avoid predictable patterns return password.split('').sort(() => crypto.randomInt(3) - 1).join('') } /** * Generate a cryptographically secure AUTH_SECRET */ function generateAuthSecret(): string { return crypto.randomBytes(32).toString('hex') } /** * Initialize or retrieve AUTH_SECRET from database * Falls back to environment variable if provided */ function initializeAuthSecret(): string { const db = getDatabase() const config = useRuntimeConfig() // Check if AUTH_SECRET is already in database const existingSecret = getSetting('auth_secret') if (existingSecret) { return existingSecret.value } // Check if provided via environment (for advanced users) const envSecret = config.authSecret if (envSecret && envSecret !== 'change-this-secret-in-production' && envSecret !== '') { // Store the env-provided secret to database for consistency setSetting('auth_secret', envSecret) console.log('â„šī¸ Using AUTH_SECRET from environment variable') return envSecret } // Generate new secure secret const newSecret = generateAuthSecret() setSetting('auth_secret', newSecret) console.log('\n' + '='.repeat(80)) console.log('🔐 GENERATED NEW AUTH_SECRET') console.log('='.repeat(80)) console.log('A new authentication secret has been generated and stored in the database.') console.log('This secret is used for session management and will persist across restarts.') console.log('='.repeat(80) + '\n') return newSecret } /** * Initialize default admin user if no admin exists * Generates secure random password and logs it on first creation */ function initializeAdminUser(): void { const db = getDatabase() const config = useRuntimeConfig() // Check if ANY admin user exists const adminExists = db.prepare('SELECT COUNT(*) as count FROM users WHERE is_admin = 1').get() as { count: number } if (adminExists.count > 0) { // Admin already exists, nothing to do return } // Determine admin username (allow override via env) const adminUsername = config.adminUsername || 'admin' // Check if environment provides a password (for backward compatibility or custom setups) let adminPassword = config.adminPassword let passwordWasGenerated = false if (!adminPassword || adminPassword === 'admin123' || adminPassword === 'Admin123!') { // Generate secure random password adminPassword = generateSecurePassword(20) passwordWasGenerated = true } // Hash the password const saltRounds = 10 const hashedPassword = bcrypt.hashSync(adminPassword, saltRounds) // Create admin user db.prepare('INSERT INTO users (username, password, is_admin) VALUES (?, ?, 1)') .run(adminUsername, hashedPassword) // Log credentials prominently console.log('\n' + '='.repeat(80)) console.log('🎉 INITIAL ADMIN ACCOUNT CREATED') console.log('='.repeat(80)) console.log(`Username: ${adminUsername}`) console.log(`Password: ${adminPassword}`) console.log('='.repeat(80)) console.log('âš ī¸ IMPORTANT: Save these credentials immediately!') console.log('This password will not be shown again.') if (passwordWasGenerated) { console.log('This password was randomly generated for security.') } console.log('You can change it after logging in via the Profile page.') console.log('='.repeat(80) + '\n') } export interface Sermon { id?: number slug: string title: string date: string dates?: string bible_references: string personal_appliance: string pastors_challenge: string worship_songs?: string created_by?: string created_at?: string } export interface User { id?: number username: string password: string email?: string first_name?: string last_name?: string is_admin: number failed_login_attempts?: number locked_until?: string | null } export interface PasswordResetCode { id?: number email: string code: string expires_at: string created_at?: string } export interface SermonNote { id?: number user_id: number sermon_id: number notes: string created_at?: string updated_at?: string } export interface Session { id?: number token: string username: string csrf_token?: string expires_at: string created_at?: string } export interface RateLimit { id?: number identifier: string endpoint: string attempts: number reset_at: string } export interface Setting { id?: number key: string value: string updated_at?: string } export function getDatabase() { if (!db) { const dbPath = join(process.cwd(), 'data', 'sermons.db') db = new Database(dbPath) // Create tables if they don't exist db.exec(` CREATE TABLE IF NOT EXISTS sermons ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT UNIQUE NOT NULL, title TEXT NOT NULL, date TEXT NOT NULL, dates TEXT, bible_references TEXT NOT NULL, personal_appliance TEXT NOT NULL, pastors_challenge TEXT NOT NULL, worship_songs TEXT, created_by TEXT, archived INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `) // Add created_by column if it doesn't exist (migration) try { db.exec(`ALTER TABLE sermons ADD COLUMN created_by TEXT`) } catch (e) { // Column already exists, ignore error } db.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password TEXT NOT NULL, email TEXT, first_name TEXT, last_name TEXT, is_admin INTEGER DEFAULT 0, failed_login_attempts INTEGER DEFAULT 0, locked_until DATETIME ) `) // Add account lockout columns if they don't exist (migration) try { db.exec(`ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER DEFAULT 0`) } catch (e) { // Column already exists, ignore error } try { db.exec(`ALTER TABLE users ADD COLUMN locked_until DATETIME`) } catch (e) { // Column already exists, ignore error } db.exec(` CREATE TABLE IF NOT EXISTS password_reset_codes ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL, code TEXT NOT NULL, expires_at DATETIME NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `) db.exec(` CREATE TABLE IF NOT EXISTS sermon_notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, sermon_id INTEGER NOT NULL, notes TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (sermon_id) REFERENCES sermons(id) ON DELETE CASCADE, UNIQUE(user_id, sermon_id) ) `) // Create indexes for foreign keys to optimize queries // SQLite doesn't automatically index foreign keys db.exec(`CREATE INDEX IF NOT EXISTS idx_sermon_notes_user_id ON sermon_notes(user_id)`) db.exec(`CREATE INDEX IF NOT EXISTS idx_sermon_notes_sermon_id ON sermon_notes(sermon_id)`) db.exec(` CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, token TEXT UNIQUE NOT NULL, username TEXT NOT NULL, csrf_token TEXT, expires_at DATETIME NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `) // Add csrf_token column if it doesn't exist (migration) try { db.exec(`ALTER TABLE sessions ADD COLUMN csrf_token TEXT`) } catch (e) { // Column already exists, ignore error } db.exec(` CREATE TABLE IF NOT EXISTS rate_limits ( id INTEGER PRIMARY KEY AUTOINCREMENT, identifier TEXT NOT NULL, endpoint TEXT NOT NULL, attempts INTEGER DEFAULT 1, reset_at DATETIME NOT NULL, UNIQUE(identifier, endpoint) ) `) db.exec(` CREATE TABLE IF NOT EXISTS settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT UNIQUE NOT NULL, value TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `) // Create additional performance indexes for frequently queried columns // Sermons: date is used for sorting and filtering db.exec(`CREATE INDEX IF NOT EXISTS idx_sermons_date ON sermons(date DESC)`) db.exec(`CREATE INDEX IF NOT EXISTS idx_sermons_archived ON sermons(archived, date DESC)`) // Users: email lookups for password reset db.exec(`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`) // Sessions: token lookups for authentication db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token)`) db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at)`) // Rate limits: identifier+endpoint lookups db.exec(`CREATE INDEX IF NOT EXISTS idx_rate_limits_identifier_endpoint ON rate_limits(identifier, endpoint)`) db.exec(`CREATE INDEX IF NOT EXISTS idx_rate_limits_reset_at ON rate_limits(reset_at)`) // Password reset codes: email+code lookups db.exec(`CREATE INDEX IF NOT EXISTS idx_password_reset_codes_email ON password_reset_codes(email)`) db.exec(`CREATE INDEX IF NOT EXISTS idx_password_reset_codes_expires_at ON password_reset_codes(expires_at)`) // Initialize AUTH_SECRET (generate if needed) initializeAuthSecret() // Initialize admin user (create if no admin exists) initializeAdminUser() } return db } export function getAllSermons(limit?: number, includeArchived: boolean = false) { const db = getDatabase() const whereClause = includeArchived ? '' : 'WHERE archived = 0' if (limit) { return db.prepare(`SELECT * FROM sermons ${whereClause} ORDER BY date DESC LIMIT ?`).all(limit) as Sermon[] } return db.prepare(`SELECT * FROM sermons ${whereClause} ORDER BY date DESC`).all() as Sermon[] } export function getArchivedSermons() { const db = getDatabase() return db.prepare('SELECT * FROM sermons WHERE archived = 1 ORDER BY date DESC').all() as Sermon[] } export function archiveSermon(id: number) { const db = getDatabase() return db.prepare('UPDATE sermons SET archived = 1 WHERE id = ?').run(id) } export function getSermonBySlug(slug: string) { const db = getDatabase() return db.prepare('SELECT * FROM sermons WHERE slug = ?').get(slug) as Sermon | undefined } export function createSermon(sermon: Sermon) { const db = getDatabase() const stmt = db.prepare(` INSERT INTO sermons (slug, title, date, dates, bible_references, personal_appliance, pastors_challenge, worship_songs, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `) return stmt.run( sermon.slug, sermon.title, sermon.date, sermon.dates || null, sermon.bible_references, sermon.personal_appliance, sermon.pastors_challenge, sermon.worship_songs || null, sermon.created_by || null ) } export function getUserByUsername(username: string) { const db = getDatabase() return db.prepare('SELECT * FROM users WHERE username = ?').get(username) as User | undefined } export function createUser(username: string, password: string, email?: string, firstName?: string, lastName?: string) { const db = getDatabase() const saltRounds = 10 const hashedPassword = bcrypt.hashSync(password, saltRounds) return db.prepare('INSERT INTO users (username, password, email, first_name, last_name, is_admin) VALUES (?, ?, ?, ?, ?, 0)') .run(username, hashedPassword, email || null, firstName || null, lastName || null) } export function getUserByEmail(email: string) { const db = getDatabase() return db.prepare('SELECT * FROM users WHERE email = ?').get(email) as User | undefined } export function createPasswordResetCode(email: string, code: string, expiresAt: string) { const db = getDatabase() // Delete any existing codes for this email db.prepare('DELETE FROM password_reset_codes WHERE email = ?').run(email) return db.prepare('INSERT INTO password_reset_codes (email, code, expires_at) VALUES (?, ?, ?)') .run(email, code, expiresAt) } export function getPasswordResetCode(email: string, code: string) { const db = getDatabase() return db.prepare("SELECT * FROM password_reset_codes WHERE email = ? AND code = ? AND expires_at > datetime('now')") .get(email, code) as PasswordResetCode | undefined } export function deletePasswordResetCode(email: string) { const db = getDatabase() return db.prepare('DELETE FROM password_reset_codes WHERE email = ?').run(email) } export function resetPasswordByEmail(email: string, newPassword: string) { const db = getDatabase() const saltRounds = 10 const hashedPassword = bcrypt.hashSync(newPassword, saltRounds) const result = db.prepare('UPDATE users SET password = ? WHERE LOWER(email) = LOWER(?)').run(hashedPassword, email) return result } export function getAllUsers() { const db = getDatabase() return db.prepare('SELECT id, username, email, first_name, last_name, is_admin, failed_login_attempts, locked_until FROM users ORDER BY username').all() as Omit[] } export function deleteUser(id: number) { const db = getDatabase() return db.prepare('DELETE FROM users WHERE id = ?').run(id) } export function updateUserRole(id: number, isAdmin: number) { const db = getDatabase() return db.prepare('UPDATE users SET is_admin = ? WHERE id = ?').run(isAdmin, id) } export function resetUserPassword(id: number, newPassword: string) { const db = getDatabase() const saltRounds = 10 const hashedPassword = bcrypt.hashSync(newPassword, saltRounds) return db.prepare('UPDATE users SET password = ? WHERE id = ?').run(hashedPassword, id) } /** * Account Lockout Functions * Provides protection against distributed brute force attacks */ /** * Check if account is currently locked * Returns true if account is locked, false if unlocked or lock has expired */ export function isAccountLocked(username: string): boolean { const db = getDatabase() const user = db.prepare("SELECT locked_until FROM users WHERE username = ? AND locked_until > datetime('now')") .get(username) as { locked_until: string } | undefined return !!user } /** * Increment failed login attempts for an account * Locks account if threshold (10 attempts) is reached */ export function incrementFailedAttempts(username: string): void { const db = getDatabase() // Get current attempt count const user = db.prepare('SELECT failed_login_attempts FROM users WHERE username = ?') .get(username) as { failed_login_attempts: number } | undefined if (!user) return const newAttempts = (user.failed_login_attempts || 0) + 1 const maxAttempts = 5 // Aligned with IP rate limiting if (newAttempts >= maxAttempts) { // Lock account for 15 minutes (aligned with IP rate limiting) const lockUntil = new Date(Date.now() + 15 * 60 * 1000).toISOString() db.prepare('UPDATE users SET failed_login_attempts = ?, locked_until = ? WHERE username = ?') .run(newAttempts, lockUntil, username) console.log(`[ACCOUNT LOCKED] User ${username} locked after ${newAttempts} failed attempts until ${lockUntil}`) } else { // Just increment counter db.prepare('UPDATE users SET failed_login_attempts = ? WHERE username = ?') .run(newAttempts, username) console.log(`[ACCOUNT SECURITY] Failed login attempt #${newAttempts} for user: ${username}`) } } /** * Reset failed login attempts on successful login */ export function resetFailedAttempts(username: string): void { const db = getDatabase() db.prepare('UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE username = ?') .run(username) } /** * Admin function to manually unlock an account */ export function unlockAccount(userId: number): void { const db = getDatabase() db.prepare('UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = ?') .run(userId) } export function getSermonNote(userId: number, sermonId: number) { const db = getDatabase() return db.prepare('SELECT * FROM sermon_notes WHERE user_id = ? AND sermon_id = ?').get(userId, sermonId) as SermonNote | undefined } export function saveSermonNote(userId: number, sermonId: number, notes: string) { const db = getDatabase() const existing = getSermonNote(userId, sermonId) if (existing) { return db.prepare('UPDATE sermon_notes SET notes = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND sermon_id = ?').run(notes, userId, sermonId) } else { return db.prepare('INSERT INTO sermon_notes (user_id, sermon_id, notes) VALUES (?, ?, ?)').run(userId, sermonId, notes) } } export function deleteSermonNote(userId: number, sermonId: number) { const db = getDatabase() return db.prepare('DELETE FROM sermon_notes WHERE user_id = ? AND sermon_id = ?').run(userId, sermonId) } // Session management functions export function createSession(token: string, username: string, expiresAt: string, csrfToken?: string) { const db = getDatabase() return db.prepare('INSERT INTO sessions (token, username, csrf_token, expires_at) VALUES (?, ?, ?, ?)') .run(token, username, csrfToken || null, expiresAt) } export function getSessionByToken(token: string) { const db = getDatabase() return db.prepare("SELECT * FROM sessions WHERE token = ? AND expires_at > datetime('now')") .get(token) as Session | undefined } export function deleteSession(token: string) { const db = getDatabase() return db.prepare('DELETE FROM sessions WHERE token = ?').run(token) } export function deleteExpiredSessions() { const db = getDatabase() return db.prepare("DELETE FROM sessions WHERE expires_at <= datetime('now')").run() } /** * Delete all sessions for a specific user * Used when password changes or security events require session invalidation */ export function deleteAllUserSessions(username: string) { const db = getDatabase() return db.prepare('DELETE FROM sessions WHERE username = ?').run(username) } /** * Delete all sessions except the current one * Used when user wants to logout other devices but keep current session */ export function deleteOtherUserSessions(username: string, currentToken: string) { const db = getDatabase() return db.prepare('DELETE FROM sessions WHERE username = ? AND token != ?').run(username, currentToken) } // Rate limiting functions export function checkRateLimit(identifier: string, endpoint: string, maxAttempts: number, windowMinutes: number): boolean { const db = getDatabase() // Clean up expired rate limit records db.prepare("DELETE FROM rate_limits WHERE reset_at <= datetime('now')").run() const existing = db.prepare('SELECT * FROM rate_limits WHERE identifier = ? AND endpoint = ?') .get(identifier, endpoint) as RateLimit | undefined if (!existing) { // First attempt - create new record const resetAt = new Date(Date.now() + windowMinutes * 60 * 1000).toISOString() db.prepare('INSERT INTO rate_limits (identifier, endpoint, attempts, reset_at) VALUES (?, ?, 1, ?)') .run(identifier, endpoint, resetAt) return true } if (existing.attempts >= maxAttempts) { // Rate limit exceeded return false } // Increment attempts db.prepare('UPDATE rate_limits SET attempts = attempts + 1 WHERE identifier = ? AND endpoint = ?') .run(identifier, endpoint) return true } export function resetRateLimit(identifier: string, endpoint: string) { const db = getDatabase() return db.prepare('DELETE FROM rate_limits WHERE identifier = ? AND endpoint = ?') .run(identifier, endpoint) } /** * Clear all rate limits for a specific endpoint * Used when admin performs actions that should clear all rate limiting (e.g., unlocking accounts) */ export function clearAllRateLimitsForEndpoint(endpoint: string) { const db = getDatabase() const result = db.prepare('DELETE FROM rate_limits WHERE endpoint = ?').run(endpoint) console.log(`[RATE LIMIT CLEARED] Cleared ${result.changes} rate limit entries for endpoint: ${endpoint}`) return result } // Settings management functions export function getSetting(key: string) { const db = getDatabase() return db.prepare('SELECT * FROM settings WHERE key = ?').get(key) as Setting | undefined } export function setSetting(key: string, value: string) { const db = getDatabase() const existing = getSetting(key) if (existing) { return db.prepare('UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?').run(value, key) } else { return db.prepare('INSERT INTO settings (key, value) VALUES (?, ?)').run(key, value) } } /** * Get the AUTH_SECRET from database * This should always exist after database initialization */ export function getAuthSecret(): string { const secret = getSetting('auth_secret') if (!secret) { throw new Error('AUTH_SECRET not found in database. Database may not be properly initialized.') } return secret.value } // Sermon retention policy functions export function deleteOldSermons(retentionDays: number) { const db = getDatabase() // Calculate the cutoff date based on retention days const cutoffDate = new Date() cutoffDate.setDate(cutoffDate.getDate() - retentionDays) const cutoffDateStr = cutoffDate.toISOString().split('T')[0] // Format as YYYY-MM-DD // Delete sermons older than the cutoff date return db.prepare('DELETE FROM sermons WHERE date < ?').run(cutoffDateStr) }