Fixed inconsistency between IP-based rate limiting and per-account lockout. Previously, users would hit the IP rate limit at 5 attempts (15 min lockout) but the account wouldn't be marked as locked until 10 attempts (30 min). This caused confusion in the admin UI where locked accounts wouldn't show the unlock button until 10 attempts were reached. Changes: - Reduced account lockout threshold from 10 to 5 failed attempts - Reduced account lockout duration from 30 to 15 minutes - Updated error message to reflect 15 minute lockout period - Added detailed logging when account gets locked - Updated README documentation to reflect correct limits Both protection layers now work in harmony: - IP-based rate limiting: 5 attempts = 15 min lockout - Per-account lockout: 5 attempts = 15 min lock This ensures the admin UI accurately shows account lock status and provides the unlock option as soon as users hit the lockout threshold. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
643 lines
20 KiB
TypeScript
643 lines
20 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)
|
||
)
|
||
`)
|
||
|
||
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
|
||
)
|
||
`)
|
||
|
||
// 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)
|
||
}
|
||
|
||
// 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)
|
||
}
|