feat: create secure coffee brewing timer with step-by-step guide and pause functionality

This commit is contained in:
Greg 2025-07-18 20:16:06 +02:00
parent 44beca8c0a
commit 4aed9e220f
7 changed files with 755 additions and 0 deletions

27
.htaccess Normal file
View File

@ -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
<Files ~ "^\.">
Order allow,deny
Deny from all
</Files>
# Prevent access to backup files
<FilesMatch "\.(bak|backup|old|tmp)$">
Order allow,deny
Deny from all
</FilesMatch>
# Enable HTTPS redirect (uncomment when using HTTPS)
# RewriteEngine On
# RewriteCond %{HTTPS} off
# RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]

56
coolify-nginx.conf Normal file
View File

@ -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;
}

62
index.html Normal file
View File

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-Content-Type-Options" content="nosniff">
<meta http-equiv="X-Frame-Options" content="DENY">
<meta http-equiv="X-XSS-Protection" content="1; mode=block">
<meta http-equiv="Referrer-Policy" content="strict-origin-when-cross-origin">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.bunny.net; font-src 'self' https://fonts.bunny.net; connect-src 'self'; script-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self';">
<title>Chemex Pour-Over Guide</title>
<link rel="stylesheet" href="style.css">
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=poppins:300,400,600&display=swap" rel="stylesheet">
</head>
<body>
<div class="container">
<header>
<h1>Chemex Brew Guide</h1>
<p>Barista-Recommended Recipe</p>
</header>
<div id="setup" class="step-card active">
<h2>Get Ready</h2>
<ul class="recipe-overview">
<li><strong>Coffee:</strong> 21g (Medium-fine grind)</li>
<li><strong>Water:</strong> 340g at 94-96°C</li>
<li><strong>Ratio:</strong> 1:16</li>
<li><strong>Total Time:</strong> ~4:00 min</li>
</ul>
<p class="tip">Preheat your Chemex and metal filter with hot water before you begin.</p>
<button id="start-btn" class="btn">Start Brewing</button>
</div>
<div id="brewing-process" class="step-card">
<div class="timer-container">
<svg class="progress-ring" width="200" height="200">
<circle class="progress-ring__bg" stroke-width="8" fill="transparent" r="90" cx="100" cy="100"/>
<circle class="progress-ring__circle" stroke-width="8" fill="transparent" r="90" cx="100" cy="100"/>
</svg>
<div class="timer-text">
<span id="timer-display">0:00</span>
<span id="total-water">0g</span>
</div>
</div>
<div class="instructions">
<h2 id="step-title"></h2>
<p id="step-instruction"></p>
</div>
<div class="controls">
<button id="pause-btn" class="btn btn-pause">Pause</button>
<button id="next-btn" class="btn">Next Step</button>
<button id="reset-btn" class="btn btn-secondary">Reset</button>
</div>
</div>
</div>
<script src="script.js?v=4"></script>
</body>
</html>

25
nixpacks.toml Normal file
View File

@ -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
"/" = "."

316
script.js Normal file
View File

@ -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);
}
});

67
security.md Normal file
View File

@ -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

202
style.css Normal file
View File

@ -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;
}
}