perf: Comprehensive efficiency optimizations
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>
This commit is contained in:
69
Dockerfile
69
Dockerfile
@@ -1,20 +1,30 @@
|
|||||||
FROM node:20-alpine
|
# ======================
|
||||||
|
# Stage 1: Dependencies
|
||||||
|
# ======================
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies (including devDependencies for build)
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
|
|
||||||
# Copy application files
|
# ======================
|
||||||
|
# Stage 2: Builder
|
||||||
|
# ======================
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy dependencies from deps stage
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
# Copy application source
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Create data directory for SQLite database
|
# Accept build arguments for Nuxt build-time configuration
|
||||||
RUN mkdir -p /app/data
|
|
||||||
|
|
||||||
# Accept build arguments
|
|
||||||
ARG SITE_URL=https://church.example.com
|
ARG SITE_URL=https://church.example.com
|
||||||
ARG EMAIL_HOST=smtp.example.com
|
ARG EMAIL_HOST=smtp.example.com
|
||||||
ARG EMAIL_PORT=587
|
ARG EMAIL_PORT=587
|
||||||
@@ -30,13 +40,37 @@ ENV EMAIL_USER=$EMAIL_USER
|
|||||||
ENV EMAIL_PASSWORD=$EMAIL_PASSWORD
|
ENV EMAIL_PASSWORD=$EMAIL_PASSWORD
|
||||||
ENV EMAIL_FROM=$EMAIL_FROM
|
ENV EMAIL_FROM=$EMAIL_FROM
|
||||||
|
|
||||||
# Security: AUTH_SECRET and admin credentials are auto-generated on first launch
|
|
||||||
# They are stored in the database and logged once to container logs
|
|
||||||
# Use: docker logs <container-name> | grep "ADMIN CREDENTIALS" to retrieve them
|
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
# Remove development dependencies after build
|
||||||
|
RUN npm prune --production
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# Stage 3: Runtime
|
||||||
|
# ======================
|
||||||
|
FROM node:20-alpine AS runtime
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dumb-init for proper signal handling
|
||||||
|
RUN apk add --no-cache dumb-init
|
||||||
|
|
||||||
|
# Create non-root user for security
|
||||||
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
|
adduser -S nuxt -u 1001
|
||||||
|
|
||||||
|
# Copy only production dependencies and built output
|
||||||
|
COPY --from=builder --chown=nuxt:nodejs /app/.output ./.output
|
||||||
|
COPY --from=builder --chown=nuxt:nodejs /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder --chown=nuxt:nodejs /app/package.json ./package.json
|
||||||
|
|
||||||
|
# Create data directory for SQLite database with proper permissions
|
||||||
|
RUN mkdir -p /app/data && chown -R nuxt:nodejs /app/data
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER nuxt
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
@@ -45,5 +79,14 @@ ENV NODE_ENV=production
|
|||||||
ENV NUXT_HOST=0.0.0.0
|
ENV NUXT_HOST=0.0.0.0
|
||||||
ENV NUXT_PORT=3000
|
ENV NUXT_PORT=3000
|
||||||
|
|
||||||
# Start the application
|
# Security: AUTH_SECRET and admin credentials are auto-generated on first launch
|
||||||
|
# They are stored in the database and logged once to container logs
|
||||||
|
# Use: docker logs <container-name> | grep "ADMIN CREDENTIALS" to retrieve them
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
|
||||||
|
CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" || exit 1
|
||||||
|
|
||||||
|
# Use dumb-init to properly handle signals
|
||||||
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
CMD ["node", ".output/server/index.mjs"]
|
CMD ["node", ".output/server/index.mjs"]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: '2024-04-03',
|
compatibilityDate: '2024-04-03',
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
|
|
||||||
modules: [
|
modules: [
|
||||||
'@nuxtjs/tailwindcss'
|
'@nuxtjs/tailwindcss'
|
||||||
],
|
],
|
||||||
@@ -26,6 +26,19 @@ export default defineNuxtConfig({
|
|||||||
|
|
||||||
css: ['~/assets/css/main.css'],
|
css: ['~/assets/css/main.css'],
|
||||||
|
|
||||||
|
// Configure route caching rules
|
||||||
|
routeRules: {
|
||||||
|
// Static assets - cache for 1 year (immutable)
|
||||||
|
'/_nuxt/**': { headers: { 'cache-control': 'public, max-age=31536000, immutable' } },
|
||||||
|
'/logos/**': { headers: { 'cache-control': 'public, max-age=31536000, immutable' } },
|
||||||
|
|
||||||
|
// API routes - no cache
|
||||||
|
'/api/**': { headers: { 'cache-control': 'no-store, no-cache, must-revalidate' } },
|
||||||
|
|
||||||
|
// HTML pages - cache for 10 minutes with revalidation
|
||||||
|
'/**': { headers: { 'cache-control': 'public, max-age=600, must-revalidate' } }
|
||||||
|
},
|
||||||
|
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
// Private runtime config (server-side only)
|
// Private runtime config (server-side only)
|
||||||
// These are automatically overridden by NUXT_<KEY> environment variables
|
// These are automatically overridden by NUXT_<KEY> environment variables
|
||||||
|
|||||||
@@ -200,17 +200,20 @@ const greeting = computed(() => {
|
|||||||
return `${timeGreeting}, ${firstName.value}!`
|
return `${timeGreeting}, ${firstName.value}!`
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch non-archived sermons for the most recent sermon
|
// Fetch all sermons in a single request (more efficient than two separate calls)
|
||||||
const { data: activeSermons } = await useFetch('/api/sermons?includeArchived=false')
|
const { data: allSermons } = await useFetch('/api/sermons?includeArchived=true')
|
||||||
|
|
||||||
// Fetch archived sermons for the previous sermons dropdown
|
|
||||||
const { data: archivedSermons } = await useFetch('/api/sermons?includeArchived=true')
|
|
||||||
|
|
||||||
const selectedSermonSlug = ref('')
|
const selectedSermonSlug = ref('')
|
||||||
|
|
||||||
|
// Filter active (non-archived) sermons client-side
|
||||||
|
const activeSermons = computed(() => {
|
||||||
|
if (!allSermons.value) return []
|
||||||
|
return allSermons.value.filter((s: any) => s.archived !== 1)
|
||||||
|
})
|
||||||
|
|
||||||
// Get today's sermon (if one exists for today's date)
|
// Get today's sermon (if one exists for today's date)
|
||||||
const todaysSermon = computed(() => {
|
const todaysSermon = computed(() => {
|
||||||
if (!activeSermons.value || activeSermons.value.length === 0) return null
|
if (activeSermons.value.length === 0) return null
|
||||||
|
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
today.setHours(0, 0, 0, 0)
|
today.setHours(0, 0, 0, 0)
|
||||||
@@ -222,7 +225,7 @@ const todaysSermon = computed(() => {
|
|||||||
if (sermonDate.getTime() === today.getTime()) {
|
if (sermonDate.getTime() === today.getTime()) {
|
||||||
return { ...s, displayDate: s.date }
|
return { ...s, displayDate: s.date }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check additional dates if they exist
|
// Check additional dates if they exist
|
||||||
if (s.dates) {
|
if (s.dates) {
|
||||||
try {
|
try {
|
||||||
@@ -232,7 +235,7 @@ const todaysSermon = computed(() => {
|
|||||||
additionalDate.setHours(0, 0, 0, 0)
|
additionalDate.setHours(0, 0, 0, 0)
|
||||||
return additionalDate.getTime() === today.getTime()
|
return additionalDate.getTime() === today.getTime()
|
||||||
})
|
})
|
||||||
|
|
||||||
if (matchingDate) {
|
if (matchingDate) {
|
||||||
return { ...s, displayDate: matchingDate }
|
return { ...s, displayDate: matchingDate }
|
||||||
}
|
}
|
||||||
@@ -241,13 +244,13 @@ const todaysSermon = computed(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get upcoming sermons (future dates)
|
// Get upcoming sermons (future dates)
|
||||||
const upcomingSermons = computed(() => {
|
const upcomingSermons = computed(() => {
|
||||||
if (!activeSermons.value) return []
|
if (activeSermons.value.length === 0) return []
|
||||||
|
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
today.setHours(0, 0, 0, 0)
|
today.setHours(0, 0, 0, 0)
|
||||||
@@ -289,8 +292,8 @@ const upcomingSermons = computed(() => {
|
|||||||
|
|
||||||
// Get archived sermons only for the previous sermons dropdown
|
// Get archived sermons only for the previous sermons dropdown
|
||||||
const previousSermons = computed(() => {
|
const previousSermons = computed(() => {
|
||||||
if (!archivedSermons.value) return []
|
if (!allSermons.value) return []
|
||||||
return archivedSermons.value.filter((s: any) => s.archived === 1)
|
return allSermons.value.filter((s: any) => s.archived === 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Check if there are any current or upcoming sermons
|
// Check if there are any current or upcoming sermons
|
||||||
|
|||||||
6
server/api/health.get.ts
Normal file
6
server/api/health.get.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default defineEventHandler(() => {
|
||||||
|
return {
|
||||||
|
status: 'healthy',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getSetting, deleteOldSermons } from '../utils/database'
|
import { getSetting, deleteOldSermons, deleteExpiredSessions, getDatabase } from '../utils/database'
|
||||||
|
|
||||||
// Map retention policy to days
|
// Map retention policy to days
|
||||||
const retentionDaysMap: Record<string, number> = {
|
const retentionDaysMap: Record<string, number> = {
|
||||||
@@ -12,7 +12,7 @@ const retentionDaysMap: Record<string, number> = {
|
|||||||
'10_years': 3650
|
'10_years': 3650
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runCleanup() {
|
async function runSermonCleanup() {
|
||||||
try {
|
try {
|
||||||
// Get the retention policy setting
|
// Get the retention policy setting
|
||||||
const setting = getSetting('sermon_retention_policy')
|
const setting = getSetting('sermon_retention_policy')
|
||||||
@@ -34,23 +34,76 @@ async function runCleanup() {
|
|||||||
console.log(`[Sermon Cleanup] No sermons to delete (policy: ${retentionPolicy})`)
|
console.log(`[Sermon Cleanup] No sermons to delete (policy: ${retentionPolicy})`)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Sermon Cleanup] Error running cleanup:', error)
|
console.error('[Sermon Cleanup] Error running sermon cleanup:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineNitroPlugin((nitroApp) => {
|
async function runSessionCleanup() {
|
||||||
console.log('[Sermon Cleanup] Scheduling daily cleanup task')
|
try {
|
||||||
|
const result = deleteExpiredSessions()
|
||||||
|
if (result.changes > 0) {
|
||||||
|
console.log(`[Session Cleanup] Deleted ${result.changes} expired session(s)`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Session Cleanup] Error cleaning expired sessions:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Run cleanup once at startup (after a short delay)
|
async function runRateLimitCleanup() {
|
||||||
|
try {
|
||||||
|
const db = getDatabase()
|
||||||
|
const result = db.prepare("DELETE FROM rate_limits WHERE reset_at <= datetime('now')").run()
|
||||||
|
if (result.changes > 0) {
|
||||||
|
console.log(`[Rate Limit Cleanup] Deleted ${result.changes} expired rate limit(s)`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Rate Limit Cleanup] Error cleaning expired rate limits:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPasswordResetCleanup() {
|
||||||
|
try {
|
||||||
|
const db = getDatabase()
|
||||||
|
const result = db.prepare("DELETE FROM password_reset_codes WHERE expires_at <= datetime('now')").run()
|
||||||
|
if (result.changes > 0) {
|
||||||
|
console.log(`[Password Reset Cleanup] Deleted ${result.changes} expired reset code(s)`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Password Reset Cleanup] Error cleaning expired password reset codes:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAllCleanupTasks() {
|
||||||
|
console.log('[Cleanup] Starting scheduled cleanup tasks')
|
||||||
|
await runSermonCleanup()
|
||||||
|
await runSessionCleanup()
|
||||||
|
await runRateLimitCleanup()
|
||||||
|
await runPasswordResetCleanup()
|
||||||
|
console.log('[Cleanup] All cleanup tasks completed')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineNitroPlugin((nitroApp) => {
|
||||||
|
console.log('[Cleanup Plugin] Initializing scheduled cleanup tasks')
|
||||||
|
|
||||||
|
// Run all cleanup tasks at startup (after a short delay)
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
console.log('[Sermon Cleanup] Running initial cleanup')
|
console.log('[Cleanup] Running initial cleanup')
|
||||||
await runCleanup()
|
await runAllCleanupTasks()
|
||||||
}, 10000) // 10 seconds after startup
|
}, 10000) // 10 seconds after startup
|
||||||
|
|
||||||
// Schedule cleanup to run daily (every 24 hours)
|
// Schedule cleanup to run daily (every 24 hours) for sermons
|
||||||
const dailyInterval = 24 * 60 * 60 * 1000 // 24 hours in milliseconds
|
const dailyInterval = 24 * 60 * 60 * 1000 // 24 hours in milliseconds
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
console.log('[Sermon Cleanup] Running scheduled cleanup')
|
await runAllCleanupTasks()
|
||||||
await runCleanup()
|
|
||||||
}, dailyInterval)
|
}, dailyInterval)
|
||||||
|
|
||||||
|
// Run session/rate-limit cleanup more frequently (every hour)
|
||||||
|
// This prevents these tables from growing too large
|
||||||
|
const hourlyInterval = 60 * 60 * 1000 // 1 hour in milliseconds
|
||||||
|
setInterval(async () => {
|
||||||
|
console.log('[Cleanup] Running hourly cleanup')
|
||||||
|
await runSessionCleanup()
|
||||||
|
await runRateLimitCleanup()
|
||||||
|
await runPasswordResetCleanup()
|
||||||
|
}, hourlyInterval)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -278,6 +278,11 @@ export function getDatabase() {
|
|||||||
UNIQUE(user_id, sermon_id)
|
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(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
@@ -317,6 +322,26 @@ export function getDatabase() {
|
|||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
// 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)
|
// Initialize AUTH_SECRET (generate if needed)
|
||||||
initializeAuthSecret()
|
initializeAuthSecret()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user