Add intelligent auto-archiving system that automatically moves sermons to the "Previous Sermons" list when they are 1 day past their most recent date. Features: - Auto-archive logic that checks both primary and additional sermon dates - Finds the most recent date across all dates for a sermon - Archives sermon 1 day after the most recent date has passed - Manual trigger via "Run Auto-Archive Now" button on admin page - Automatic daily execution via scheduled cleanup task - Clear admin UI with explanatory text and status messages - Manual archive/unarchive functionality preserved Implementation: - Added getMostRecentSermonDate() helper to find latest date from primary and additional dates - Added autoArchiveOldSermons() function to database utils - Created /api/sermons/auto-archive endpoint for manual triggering - Integrated into daily cleanup plugin schedule - Updated admin UI with auto-archive button and status indicators - Added unarchiveSermon() function for completeness The system runs automatically every 24 hours and can be manually triggered by admins. Sermons are moved to the previous sermons dropdown on the home page exactly 1 day after their final presentation date, ensuring the main page always shows current and upcoming content while preserving access to past sermons. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
735 lines
24 KiB
TypeScript
735 lines
24 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 unarchiveSermon(id: number) {
|
||
const db = getDatabase()
|
||
return db.prepare('UPDATE sermons SET archived = 0 WHERE id = ?').run(id)
|
||
}
|
||
|
||
// Get the most recent date from a sermon (checking both primary date and additional dates)
|
||
function getMostRecentSermonDate(sermon: any): Date {
|
||
const dates: string[] = [sermon.date]
|
||
|
||
// Add additional dates if they exist
|
||
if (sermon.dates) {
|
||
try {
|
||
const additionalDates = JSON.parse(sermon.dates)
|
||
dates.push(...additionalDates)
|
||
} catch {
|
||
// If parsing fails, just use primary date
|
||
}
|
||
}
|
||
|
||
// Convert all dates to Date objects and find the most recent
|
||
const dateTimes = dates.map(dateStr => {
|
||
const date = new Date(dateStr + 'T00:00:00')
|
||
return date.getTime()
|
||
})
|
||
|
||
return new Date(Math.max(...dateTimes))
|
||
}
|
||
|
||
export function autoArchiveOldSermons(): { archivedCount: number } {
|
||
const db = getDatabase()
|
||
|
||
// Get all non-archived sermons
|
||
const sermons = db.prepare('SELECT * FROM sermons WHERE archived = 0').all() as any[]
|
||
|
||
const now = new Date()
|
||
now.setHours(0, 0, 0, 0) // Start of today
|
||
|
||
let archivedCount = 0
|
||
|
||
for (const sermon of sermons) {
|
||
const mostRecentDate = getMostRecentSermonDate(sermon)
|
||
|
||
// Calculate days difference
|
||
const oneDayAfterSermon = new Date(mostRecentDate)
|
||
oneDayAfterSermon.setDate(oneDayAfterSermon.getDate() + 1)
|
||
|
||
// If it's been at least 1 day since the most recent sermon date, archive it
|
||
if (now >= oneDayAfterSermon) {
|
||
archiveSermon(sermon.id!)
|
||
archivedCount++
|
||
}
|
||
}
|
||
|
||
return { archivedCount }
|
||
}
|
||
|
||
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)
|
||
}
|