diff --git a/Dockerfile b/Dockerfile index cab0382..7fd3d28 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,30 @@ -FROM node:20-alpine +# ====================== +# Stage 1: Dependencies +# ====================== +FROM node:20-alpine AS deps WORKDIR /app # Copy package files COPY package*.json ./ -# Install dependencies -RUN npm install +# Install dependencies (including devDependencies for build) +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 . . -# Create data directory for SQLite database -RUN mkdir -p /app/data - -# Accept build arguments +# Accept build arguments for Nuxt build-time configuration ARG SITE_URL=https://church.example.com ARG EMAIL_HOST=smtp.example.com ARG EMAIL_PORT=587 @@ -30,13 +40,37 @@ ENV EMAIL_USER=$EMAIL_USER ENV EMAIL_PASSWORD=$EMAIL_PASSWORD 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 | grep "ADMIN CREDENTIALS" to retrieve them - # Build the application 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 3000 @@ -45,5 +79,14 @@ ENV NODE_ENV=production ENV NUXT_HOST=0.0.0.0 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 | 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"] diff --git a/nuxt.config.ts b/nuxt.config.ts index b9a54ce..1767a89 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -2,7 +2,7 @@ export default defineNuxtConfig({ compatibilityDate: '2024-04-03', devtools: { enabled: true }, - + modules: [ '@nuxtjs/tailwindcss' ], @@ -26,6 +26,19 @@ export default defineNuxtConfig({ 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: { // Private runtime config (server-side only) // These are automatically overridden by NUXT_ environment variables diff --git a/pages/index.vue b/pages/index.vue index 050ae2a..1d902f9 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -200,17 +200,20 @@ const greeting = computed(() => { return `${timeGreeting}, ${firstName.value}!` }) -// Fetch non-archived sermons for the most recent sermon -const { data: activeSermons } = await useFetch('/api/sermons?includeArchived=false') - -// Fetch archived sermons for the previous sermons dropdown -const { data: archivedSermons } = await useFetch('/api/sermons?includeArchived=true') +// Fetch all sermons in a single request (more efficient than two separate calls) +const { data: allSermons } = await useFetch('/api/sermons?includeArchived=true') 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) const todaysSermon = computed(() => { - if (!activeSermons.value || activeSermons.value.length === 0) return null + if (activeSermons.value.length === 0) return null const today = new Date() today.setHours(0, 0, 0, 0) @@ -222,7 +225,7 @@ const todaysSermon = computed(() => { if (sermonDate.getTime() === today.getTime()) { return { ...s, displayDate: s.date } } - + // Check additional dates if they exist if (s.dates) { try { @@ -232,7 +235,7 @@ const todaysSermon = computed(() => { additionalDate.setHours(0, 0, 0, 0) return additionalDate.getTime() === today.getTime() }) - + if (matchingDate) { return { ...s, displayDate: matchingDate } } @@ -241,13 +244,13 @@ const todaysSermon = computed(() => { } } } - + return null }) // Get upcoming sermons (future dates) const upcomingSermons = computed(() => { - if (!activeSermons.value) return [] + if (activeSermons.value.length === 0) return [] const today = new Date() today.setHours(0, 0, 0, 0) @@ -289,8 +292,8 @@ const upcomingSermons = computed(() => { // Get archived sermons only for the previous sermons dropdown const previousSermons = computed(() => { - if (!archivedSermons.value) return [] - return archivedSermons.value.filter((s: any) => s.archived === 1) + if (!allSermons.value) return [] + return allSermons.value.filter((s: any) => s.archived === 1) }) // Check if there are any current or upcoming sermons diff --git a/server/api/health.get.ts b/server/api/health.get.ts new file mode 100644 index 0000000..ba0a1e8 --- /dev/null +++ b/server/api/health.get.ts @@ -0,0 +1,6 @@ +export default defineEventHandler(() => { + return { + status: 'healthy', + timestamp: new Date().toISOString() + } +}) diff --git a/server/plugins/sermon-cleanup.ts b/server/plugins/sermon-cleanup.ts index 465f44e..72aa638 100644 --- a/server/plugins/sermon-cleanup.ts +++ b/server/plugins/sermon-cleanup.ts @@ -1,4 +1,4 @@ -import { getSetting, deleteOldSermons } from '../utils/database' +import { getSetting, deleteOldSermons, deleteExpiredSessions, getDatabase } from '../utils/database' // Map retention policy to days const retentionDaysMap: Record = { @@ -12,7 +12,7 @@ const retentionDaysMap: Record = { '10_years': 3650 } -async function runCleanup() { +async function runSermonCleanup() { try { // Get the retention policy setting const setting = getSetting('sermon_retention_policy') @@ -34,23 +34,76 @@ async function runCleanup() { console.log(`[Sermon Cleanup] No sermons to delete (policy: ${retentionPolicy})`) } } catch (error) { - console.error('[Sermon Cleanup] Error running cleanup:', error) + console.error('[Sermon Cleanup] Error running sermon cleanup:', error) } } -export default defineNitroPlugin((nitroApp) => { - console.log('[Sermon Cleanup] Scheduling daily cleanup task') +async function runSessionCleanup() { + 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 () => { - console.log('[Sermon Cleanup] Running initial cleanup') - await runCleanup() + console.log('[Cleanup] Running initial cleanup') + await runAllCleanupTasks() }, 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 setInterval(async () => { - console.log('[Sermon Cleanup] Running scheduled cleanup') - await runCleanup() + await runAllCleanupTasks() }, 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) }) diff --git a/server/utils/database.ts b/server/utils/database.ts index 988fad9..65b0eb6 100644 --- a/server/utils/database.ts +++ b/server/utils/database.ts @@ -278,6 +278,11 @@ export function getDatabase() { 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 ( @@ -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) initializeAuthSecret()