Implemented all 5 critical efficiency improvements to optimize performance, reduce resource usage, and improve scalability. ## 1. Database Indexes - Added indexes on sermon_notes foreign keys (user_id, sermon_id) - Added composite index on sermons (archived, date DESC) - Added indexes on frequently queried columns across all tables - Impact: Faster queries as data grows, better JOIN performance ## 2. Eliminated N+1 Query Pattern - Reduced 2 API calls to 1 on home page load - Changed from separate active/archived fetches to single call - Filter archived sermons client-side using computed properties - Impact: 50% reduction in HTTP requests per page load ## 3. Scheduled Database Cleanup - Extended existing plugin to clean expired sessions hourly - Added cleanup for expired rate limits every hour - Added cleanup for expired password reset codes every hour - Sermon cleanup continues to run daily based on retention policy - Impact: Prevents database table growth, better performance ## 4. Multi-stage Docker Build - Implemented 3-stage build: deps -> builder -> runtime - Separated build-time and runtime dependencies - Added non-root user (nuxt:nodejs) for security - Integrated dumb-init for proper signal handling - Added health check endpoint at /api/health - Impact: Smaller image size, faster deployments, better security ## 5. HTTP Caching - Static assets: 1 year cache (immutable) - Logos/images: 1 year cache (immutable) - API routes: No cache (always fresh) - HTML pages: 10 minute cache with revalidation - Impact: Reduced bandwidth, faster page loads, less server load All optimizations follow best practices and maintain backward compatibility with existing functionality. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
679 lines
22 KiB
TypeScript
679 lines
22 KiB
TypeScript
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<User, 'password'>[]
|
||
}
|
||
|
||
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)
|
||
}
|