diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..8156ab7 --- /dev/null +++ b/.htaccess @@ -0,0 +1,27 @@ +# Security Headers +Header always set X-Content-Type-Options nosniff +Header always set X-Frame-Options DENY +Header always set X-XSS-Protection "1; mode=block" +Header always set Referrer-Policy "strict-origin-when-cross-origin" +Header always set Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self'; script-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self';" + +# Disable server signature +ServerTokens Prod +ServerSignature Off + +# Prevent access to sensitive files + + Order allow,deny + Deny from all + + +# Prevent access to backup files + + Order allow,deny + Deny from all + + +# Enable HTTPS redirect (uncomment when using HTTPS) +# RewriteEngine On +# RewriteCond %{HTTPS} off +# RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] diff --git a/coolify-nginx.conf b/coolify-nginx.conf new file mode 100644 index 0000000..e312604 --- /dev/null +++ b/coolify-nginx.conf @@ -0,0 +1,56 @@ +# Coolify Static Site Security Configuration +# This file provides nginx security headers for static sites deployed on Coolify +# Place this in your project root or reference it in your nixpacks.toml + +# Security headers for static sites served by nginx in Coolify +add_header X-Content-Type-Options nosniff always; +add_header X-Frame-Options DENY always; +add_header X-XSS-Protection "1; mode=block" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self'; script-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self';" always; + +# Additional security measures +add_header X-Permitted-Cross-Domain-Policies none always; +add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + +# Hide nginx version +server_tokens off; + +# Prevent access to hidden files +location ~ /\. { + deny all; + access_log off; + log_not_found off; +} + +# Prevent access to backup and temporary files +location ~* \.(bak|backup|old|tmp|swp|swo|log)$ { + deny all; + access_log off; + log_not_found off; +} + +# Security for common files +location ~* \.(htaccess|htpasswd|ini|log|sh|sql|conf)$ { + deny all; + access_log off; + log_not_found off; +} + +# Optimize static file serving +location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options DENY always; +} + +# Main location for HTML files +location / { + try_files $uri $uri/ /index.html; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options DENY always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self'; script-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self';" always; +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..66296a8 --- /dev/null +++ b/index.html @@ -0,0 +1,62 @@ + + + + + + + + + + + Chemex Pour-Over Guide + + + + + +
+
+

Chemex Brew Guide

+

Barista-Recommended Recipe

+
+ +
+

Get Ready

+ +

Preheat your Chemex and metal filter with hot water before you begin.

+ +
+ +
+
+ + + + +
+ 0:00 + 0g +
+
+ +
+

+

+
+ +
+ + + +
+
+
+ + + + diff --git a/nixpacks.toml b/nixpacks.toml new file mode 100644 index 0000000..71019c1 --- /dev/null +++ b/nixpacks.toml @@ -0,0 +1,25 @@ +# Nixpacks configuration for Coolify static site deployment +# This ensures your Coffee website is deployed securely with nginx + +[variables] +# Enable static site serving with nginx +NIXPACKS_NO_CACHE = "1" + +[phases.setup] +# Install nginx and required packages +nixPkgs = ["nginx"] + +[phases.build] +# Copy security configuration +cmds = [ + "mkdir -p /etc/nginx/conf.d", + "cp coolify-nginx.conf /etc/nginx/conf.d/security.conf" +] + +[phases.start] +# Start nginx with security configuration +cmd = "nginx -g 'daemon off;'" + +[staticAssets] +# Configure static asset serving +"/" = "." diff --git a/script.js b/script.js new file mode 100644 index 0000000..e678dd1 --- /dev/null +++ b/script.js @@ -0,0 +1,316 @@ +// Security utility functions +function sanitizeText(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function validateElement(element) { + return element && element.nodeType === Node.ELEMENT_NODE; +} + +// Secure DOM manipulation +function secureSetTextContent(element, text) { + if (!validateElement(element)) { + console.error('Invalid element provided'); + return false; + } + element.textContent = sanitizeText(text); + return true; +} + +// Audio notification function +function playTimerCompleteSound() { + try { + // Create audio context + const audioContext = new (window.AudioContext || window.webkitAudioContext)(); + + // Create a pleasant chime sound using multiple tones + const frequencies = [523.25, 659.25, 783.99]; // C5, E5, G5 - major chord + const duration = 0.8; // seconds + + frequencies.forEach((freq, index) => { + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + oscillator.frequency.setValueAtTime(freq, audioContext.currentTime); + oscillator.type = 'sine'; + + // Create a gentle fade-in and fade-out + const startTime = audioContext.currentTime + (index * 0.1); + const endTime = startTime + duration; + + gainNode.gain.setValueAtTime(0, startTime); + gainNode.gain.linearRampToValueAtTime(0.1, startTime + 0.1); + gainNode.gain.exponentialRampToValueAtTime(0.01, endTime); + + oscillator.start(startTime); + oscillator.stop(endTime); + }); + + console.log('Timer complete sound played'); + } catch (error) { + console.log('Could not play sound:', error); + // Fallback: try to use a simple beep + try { + const audioContext = new (window.AudioContext || window.webkitAudioContext)(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + oscillator.frequency.setValueAtTime(800, audioContext.currentTime); + oscillator.type = 'sine'; + + gainNode.gain.setValueAtTime(0.1, audioContext.currentTime); + gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.5); + + oscillator.start(); + oscillator.stop(audioContext.currentTime + 0.5); + } catch (fallbackError) { + console.log('Audio not supported in this browser'); + } + } +} + +document.addEventListener('DOMContentLoaded', () => { + const setupCard = document.getElementById('setup'); + const brewingCard = document.getElementById('brewing-process'); + const startBtn = document.getElementById('start-btn'); + const nextBtn = document.getElementById('next-btn'); + const resetBtn = document.getElementById('reset-btn'); + const pauseBtn = document.getElementById('pause-btn'); + + // Debug: Check if pause button exists + if (!pauseBtn) { + console.error('Pause button not found!'); + } else { + console.log('Pause button found successfully'); + } + + const timerDisplay = document.getElementById('timer-display'); + const totalWaterDisplay = document.getElementById('total-water'); + const stepTitle = document.getElementById('step-title'); + const stepInstruction = document.getElementById('step-instruction'); + + const progressRing = document.querySelector('.progress-ring__circle'); + const radius = progressRing.r.baseVal.value; + const circumference = 2 * Math.PI * radius; + progressRing.style.strokeDasharray = `${circumference} ${circumference}`; + progressRing.style.strokeDashoffset = circumference; + + const brewSteps = [ + { + title: 'Bloom', + instruction: 'Pour 50g of water evenly over the coffee grounds. Wait for the coffee to degas.', + duration: 45, // seconds + water: 50, // grams + }, + { + title: 'Second Pour', + instruction: 'Slowly pour in circles until the total weight reaches 200g. Avoid pouring on the edges.', + duration: 60, + water: 200, + }, + { + title: 'Final Pour', + instruction: 'Continue pouring in slow circles until you reach the final weight of 340g.', + duration: 60, + water: 340, + }, + { + title: 'Let it Drain', + instruction: 'Allow all the water to drain through the coffee bed. This should take about 30-60 seconds.', + duration: 60, + water: 340, + }, + { + title: 'Enjoy!', + instruction: 'Your delicious Chemex coffee is ready. Remove the filter, swirl, and serve.', + duration: 0, + water: 340, + } + ]; + + let currentStepIndex = 0; + let timerInterval; + let secondsElapsed = 0; + let totalSeconds = 0; + + // Step-specific timer states - each step has its own pause state + let stepTimerStates = brewSteps.map(() => ({ + isPaused: false, + pausedTime: 0, + secondsElapsed: 0, + isCompleted: false + })); + + function setProgress(percent) { + const offset = circumference - (percent / 100) * circumference; + progressRing.style.strokeDashoffset = offset; + } + + function formatTime(seconds) { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; + } + + function startTimer() { + const step = brewSteps[currentStepIndex]; + const stepState = stepTimerStates[currentStepIndex]; + totalSeconds = step.duration; + + // Use step-specific timer state + if (!stepState.isPaused) { + // Starting fresh or step not paused + secondsElapsed = stepState.secondsElapsed; + } else { + // Resuming from pause + secondsElapsed = stepState.pausedTime; + stepState.isPaused = false; + pauseBtn.classList.remove('paused'); + secureSetTextContent(pauseBtn, 'Pause'); + } + + updateTimerDisplay(); + const percent = (secondsElapsed / totalSeconds) * 100; + setProgress(percent); + + if (totalSeconds === 0) return; + + timerInterval = setInterval(() => { + const currentStepState = stepTimerStates[currentStepIndex]; + if (!currentStepState.isPaused) { + secondsElapsed++; + currentStepState.secondsElapsed = secondsElapsed; + updateTimerDisplay(); + const percent = (secondsElapsed / totalSeconds) * 100; + setProgress(percent); + + if (secondsElapsed >= totalSeconds) { + clearInterval(timerInterval); + currentStepState.isCompleted = true; + // Play pleasant sound when timer completes + playTimerCompleteSound(); + } + } + }, 1000); + } + + function updateTimerDisplay() { + secureSetTextContent(timerDisplay, formatTime(secondsElapsed)); + } + + function updateUIForStep() { + const step = brewSteps[currentStepIndex]; + const stepState = stepTimerStates[currentStepIndex]; + + if (!step) { + console.error('Invalid step index'); + return; + } + + secureSetTextContent(stepTitle, step.title); + secureSetTextContent(stepInstruction, step.instruction); + secureSetTextContent(totalWaterDisplay, `${step.water}g`); + + if (currentStepIndex === brewSteps.length - 1) { + secureSetTextContent(nextBtn, 'Brew Again'); + } else { + secureSetTextContent(nextBtn, 'Next Step'); + } + + // Reset pause button state for the new step + if (stepState.isPaused) { + pauseBtn.classList.add('paused'); + secureSetTextContent(pauseBtn, 'Resume'); + } else { + pauseBtn.classList.remove('paused'); + secureSetTextContent(pauseBtn, 'Pause'); + } + + clearInterval(timerInterval); + startTimer(); + } + + startBtn.addEventListener('click', () => { + setupCard.classList.remove('active'); + brewingCard.style.display = 'block'; + // A little trick to trigger the animation + setTimeout(() => brewingCard.classList.add('active'), 10); + updateUIForStep(); + }); + + nextBtn.addEventListener('click', () => { + // Bounds checking for security + if (currentStepIndex < 0 || currentStepIndex >= brewSteps.length - 1) { + if (currentStepIndex >= brewSteps.length - 1) { + reset(); + return; + } + } + currentStepIndex = Math.max(0, Math.min(currentStepIndex + 1, brewSteps.length - 1)); + updateUIForStep(); + }); + + resetBtn.addEventListener('click', reset); + + // Pause/Resume functionality - now step-specific + function togglePause() { + const currentStepState = stepTimerStates[currentStepIndex]; + console.log('Pause button clicked! Current step state:', currentStepState.isPaused); + + if (!currentStepState.isPaused) { + // Pause the current step's timer + currentStepState.isPaused = true; + currentStepState.pausedTime = secondsElapsed; + pauseBtn.classList.add('paused'); + secureSetTextContent(pauseBtn, 'Resume'); + console.log('Step', currentStepIndex, 'timer paused at:', currentStepState.pausedTime, 'seconds'); + } else { + // Resume the current step's timer + currentStepState.isPaused = false; + pauseBtn.classList.remove('paused'); + secureSetTextContent(pauseBtn, 'Pause'); + console.log('Step', currentStepIndex, 'timer resumed from:', currentStepState.pausedTime, 'seconds'); + } + } + + if (pauseBtn) { + pauseBtn.addEventListener('click', togglePause); + console.log('Pause button event listener added'); + } else { + console.error('Cannot add event listener - pause button not found'); + } + + function reset() { + clearInterval(timerInterval); + currentStepIndex = 0; + secondsElapsed = 0; + totalSeconds = 0; + + // Reset all step-specific timer states + stepTimerStates = brewSteps.map(() => ({ + isPaused: false, + pausedTime: 0, + secondsElapsed: 0, + isCompleted: false + })); + + // Reset pause button state + pauseBtn.classList.remove('paused'); + secureSetTextContent(pauseBtn, 'Pause'); + + brewingCard.classList.remove('active'); + setupCard.classList.add('active'); + setTimeout(() => brewingCard.style.display = 'none', 500); // Match CSS transition + + updateTimerDisplay(); + setProgress(0); + } +}); diff --git a/security.md b/security.md new file mode 100644 index 0000000..984c839 --- /dev/null +++ b/security.md @@ -0,0 +1,67 @@ +# Security Implementation Report + +## Security Measures Implemented + +### 1. HTTP Security Headers +- **X-Content-Type-Options**: Prevents MIME type sniffing attacks +- **X-Frame-Options**: Prevents clickjacking attacks by denying iframe embedding +- **X-XSS-Protection**: Enables browser XSS filtering +- **Referrer-Policy**: Controls referrer information sent with requests +- **Content-Security-Policy**: Comprehensive CSP to prevent XSS and data injection + +### 2. JavaScript Security Enhancements +- **Input Sanitization**: Added `sanitizeText()` function to prevent XSS +- **DOM Validation**: Added `validateElement()` to ensure safe DOM manipulation +- **Secure Text Setting**: Created `secureSetTextContent()` for safe content updates +- **Bounds Checking**: Added array bounds validation to prevent out-of-bounds access +- **Error Handling**: Improved error handling and logging + +### 3. Server Configuration +- **Apache .htaccess**: Security headers and file access restrictions +- **Hidden Files Protection**: Prevents access to sensitive configuration files +- **Backup Files Protection**: Blocks access to backup and temporary files +- **HTTPS Redirect**: Ready-to-enable HTTPS enforcement + +### 4. Development Security +- **CSP Compliance**: All external resources properly whitelisted +- **No Inline Scripts**: All JavaScript moved to external files +- **Font Security**: Secure loading of Google Fonts with proper CSP + +### Web Server Compatibility + +### Apache +- Use the `.htaccess` file for Apache web servers +- Place it in your website's root directory +- Requires mod_headers module to be enabled + +### Nginx +- Use the `nginx.conf` configuration for nginx servers +- Include the configuration in your nginx server block +- Update the `root` path to match your website directory +- Restart nginx after applying changes + +### Coolify (Recommended) +- **Uses nginx by default** for static sites via Nixpacks +- Use `coolify-nginx.conf` for security headers +- Use `nixpacks.toml` to configure the build process +- Automatically handles SSL/TLS certificates via Traefik proxy +- No manual server configuration required + +### Development Server (Python) +- The security headers are already implemented in the HTML meta tags +- Additional server-level security requires Apache or nginx in production +- **No Inline Scripts**: All JavaScript moved to external files +- **Font Security**: Secure loading of Google Fonts with proper CSP + +## Security Best Practices Applied +- Defense in depth approach +- Input validation and sanitization +- Secure coding practices +- Proper error handling +- Content Security Policy implementation + +## Next Steps for Production +1. Enable HTTPS and update CSP accordingly +2. Implement proper logging and monitoring +3. Regular security audits and updates +4. Consider implementing Subresource Integrity (SRI) for external resources diff --git a/style.css b/style.css new file mode 100644 index 0000000..3c7c88b --- /dev/null +++ b/style.css @@ -0,0 +1,202 @@ +:root { + --bg-color: #f4f1eb; + --text-color: #4a4a4a; + --accent-color: #6f4e37; + --accent-light: #a08a7b; + --card-bg: #ffffff; + --border-color: #e0e0e0; +} + +body { + font-family: 'Poppins', sans-serif; + background-color: var(--bg-color); + color: var(--text-color); + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + margin: 0; +} + +.container { + width: 100%; + max-width: 420px; + padding: 2rem; + text-align: center; +} + +header h1 { + font-size: 2rem; + color: var(--accent-color); + font-weight: 600; + margin-bottom: 0.25rem; +} + +header p { + font-size: 1rem; + color: var(--accent-light); + margin-top: 0; +} + +.step-card { + background-color: var(--card-bg); + border-radius: 15px; + padding: 2rem; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.05); + display: none; /* Hidden by default */ +} + +.step-card.active { + display: block; +} + +#setup h2 { + margin-top: 0; + font-weight: 400; + color: var(--accent-color); +} + +.recipe-overview { + list-style: none; + padding: 0; + margin: 1.5rem 0; + text-align: left; +} + +.recipe-overview li { + padding: 0.5rem 0; + border-bottom: 1px solid var(--border-color); +} + +.recipe-overview li:last-child { + border-bottom: none; +} + +.recipe-overview strong { + color: var(--accent-color); +} + +.tip { + font-size: 0.9rem; + color: var(--accent-light); + background-color: var(--bg-color); + padding: 0.75rem; + border-radius: 8px; +} + +.btn { + background-color: var(--accent-color); + color: white; + border: none; + padding: 0.8rem 1.5rem; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: background-color 0.3s, transform 0.2s; + width: 100%; + margin-top: 1rem; +} + +.btn:hover { + background-color: #5a3f2e; + transform: translateY(-2px); +} + +.btn-secondary { + background-color: transparent; + color: var(--accent-light); + border: 1px solid var(--border-color); +} + +.btn-secondary:hover { + background-color: var(--bg-color); + color: var(--accent-color); +} + +.btn-pause { + background-color: #f39c12; + color: white; +} + +.btn-pause:hover { + background-color: #e67e22; +} + +.btn-pause.paused { + background-color: #27ae60; +} + +.btn-pause.paused:hover { + background-color: #229954; +} + +#brewing-process { + display: none; /* Initially hidden */ +} + +.timer-container { + position: relative; + margin: 0 auto 2rem; + width: 200px; + height: 200px; +} + +.progress-ring__circle { + transform-origin: center; + transform: rotate(-90deg); + stroke: var(--accent-color); + transition: stroke-dashoffset 0.5s; +} + +.progress-ring__bg { + stroke: var(--border-color); +} + +.timer-text { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; +} + +#timer-display { + font-size: 3rem; + font-weight: 600; + color: var(--accent-color); +} + +#total-water { + font-size: 1.2rem; + color: var(--accent-light); +} + +.instructions h2 { + font-size: 1.5rem; + font-weight: 400; + color: var(--accent-color); + margin-bottom: 0.5rem; +} + +.instructions p { + font-size: 1rem; + line-height: 1.6; + margin-top: 0; +} + +.controls { + margin-top: 2rem; +} + +@media (max-width: 480px) { + .container { + padding: 1rem; + } + + .step-card { + padding: 1.5rem; + } +}