feat: create secure coffee brewing timer with step-by-step guide and pause functionality
This commit is contained in:
parent
44beca8c0a
commit
4aed9e220f
27
.htaccess
Normal file
27
.htaccess
Normal 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
56
coolify-nginx.conf
Normal 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
62
index.html
Normal 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
25
nixpacks.toml
Normal 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
316
script.js
Normal 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
67
security.md
Normal 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
202
style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user